diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 16:03:42 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 16:03:42 +0000 |
commit | 66cec45960ce1d9c794e9399de15c138acb18aed (patch) | |
tree | 59cd19d69e9d56b7989b080da7c20ef1a3fe2a5a /ansible_collections/community/crypto/plugins | |
parent | Initial commit. (diff) | |
download | ansible-66cec45960ce1d9c794e9399de15c138acb18aed.tar.xz ansible-66cec45960ce1d9c794e9399de15c138acb18aed.zip |
Adding upstream version 7.3.0+dfsg.upstream/7.3.0+dfsgupstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'ansible_collections/community/crypto/plugins')
94 files changed, 29415 insertions, 0 deletions
diff --git a/ansible_collections/community/crypto/plugins/action/openssl_privatekey_pipe.py b/ansible_collections/community/crypto/plugins/action/openssl_privatekey_pipe.py new file mode 100644 index 00000000..dc1a1697 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/action/openssl_privatekey_pipe.py @@ -0,0 +1,108 @@ +# -*- 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 + + +import base64 + +from ansible.module_utils.common.text.converters import to_native, to_bytes + +from ansible_collections.community.crypto.plugins.plugin_utils.action_module import ActionModuleBase + +from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import ( + OpenSSLObjectError, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.privatekey import ( + select_backend, + get_privatekey_argument_spec, +) + + +class PrivateKeyModule(object): + def __init__(self, module, module_backend): + self.module = module + self.module_backend = module_backend + self.check_mode = module.check_mode + self.changed = False + self.return_current_key = module.params['return_current_key'] + + if module.params['content'] is not None: + if module.params['content_base64']: + try: + data = base64.b64decode(module.params['content']) + except Exception as e: + module.fail_json(msg='Cannot decode Base64 encoded data: {0}'.format(e)) + else: + data = to_bytes(module.params['content']) + module_backend.set_existing(data) + + def generate(self, module): + """Generate a keypair.""" + + if self.module_backend.needs_regeneration(): + # Regenerate + if not self.check_mode: + self.module_backend.generate_private_key() + privatekey_data = self.module_backend.get_private_key_data() + self.privatekey_bytes = privatekey_data + self.changed = True + elif self.module_backend.needs_conversion(): + # Convert + if not self.check_mode: + self.module_backend.convert_private_key() + privatekey_data = self.module_backend.get_private_key_data() + self.privatekey_bytes = privatekey_data + self.changed = True + + def dump(self): + """Serialize the object into a dictionary.""" + result = self.module_backend.dump(include_key=self.changed or self.return_current_key) + result['changed'] = self.changed + return result + + +class ActionModule(ActionModuleBase): + @staticmethod + def setup_module(): + argument_spec = get_privatekey_argument_spec() + argument_spec.argument_spec.update(dict( + content=dict(type='str', no_log=True), + content_base64=dict(type='bool', default=False), + return_current_key=dict(type='bool', default=False), + )) + return argument_spec, dict( + supports_check_mode=True, + ) + + @staticmethod + def run_module(module): + backend, module_backend = select_backend( + module=module, + backend=module.params['select_crypto_backend'], + ) + + try: + private_key = PrivateKeyModule(module, module_backend) + private_key.generate(module) + result = private_key.dump() + if private_key.return_current_key: + # In case the module's input (`content`) is returned as `privatekey`: + # Since `content` is no_log=True, `privatekey`'s value will get replaced by + # VALUE_SPECIFIED_IN_NO_LOG_PARAMETER. To avoid this, we remove the value of + # `content` from module.no_log_values. Since we explicitly set + # `module.no_log = True`, this should be safe. + module.no_log = True + try: + module.no_log_values.remove(module.params['content']) + except KeyError: + pass + module.params['content'] = 'ANSIBLE_NO_LOG_VALUE' + module.exit_json(**result) + except OpenSSLObjectError as exc: + module.fail_json(msg=to_native(exc)) diff --git a/ansible_collections/community/crypto/plugins/doc_fragments/acme.py b/ansible_collections/community/crypto/plugins/doc_fragments/acme.py new file mode 100644 index 00000000..a50cedd6 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/doc_fragments/acme.py @@ -0,0 +1,139 @@ +# -*- 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 + + +class ModuleDocFragment(object): + + # Standard files documentation fragment + DOCUMENTATION = 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 C(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 + C(account_key_content))." + - "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: + 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 C(account_key_content)." + - "Required if C(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 C(account_key_src)." + - "Required if C(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 + acme_version: + description: + - "The ACME version of the endpoint." + - "Must be C(1) for the classic Let's Encrypt and Buypass ACME endpoints, + or C(2) for standardized ACME v2 endpoints." + - "The value C(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 C(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 C(auto), which tries to use C(cryptography) if available, and falls back to + C(openssl). + - If set to C(openssl), will try to use the C(openssl) binary. + - If set to C(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 +''' diff --git a/ansible_collections/community/crypto/plugins/doc_fragments/attributes.py b/ansible_collections/community/crypto/plugins/doc_fragments/attributes.py new file mode 100644 index 00000000..11f6b575 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/doc_fragments/attributes.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see COPYING 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 + + +class ModuleDocFragment(object): + + # Standard documentation fragment + DOCUMENTATION = r''' +options: {} +attributes: + check_mode: + description: Can run in C(check_mode) and return changed status prediction without modifying target. + diff_mode: + description: Will return details on what has changed (or possibly needs changing in C(check_mode)), when in diff mode. +''' + + # Should be used together with the standard fragment + INFO_MODULE = r''' +options: {} +attributes: + check_mode: + support: full + details: + - This action does not modify state. + diff_mode: + support: N/A + details: + - This action does not modify state. +''' + + ACTIONGROUP_ACME = r''' +options: {} +attributes: + action_group: + description: Use C(group/acme) or C(group/community.crypto.acme) in C(module_defaults) to set defaults for this module. + support: full + membership: + - community.crypto.acme + - acme +''' + + FACTS = r''' +options: {} +attributes: + facts: + description: Action returns an C(ansible_facts) dictionary that will update existing host facts. +''' + + # Should be used together with the standard fragment and the FACTS fragment + FACTS_MODULE = r''' +options: {} +attributes: + check_mode: + support: full + details: + - This action does not modify state. + diff_mode: + support: N/A + details: + - This action does not modify state. + facts: + support: full +''' + + FILES = r''' +options: {} +attributes: + safe_file_operations: + description: Uses Ansible's strict file operation functions to ensure proper permissions and avoid data corruption. +''' + + FLOW = r''' +options: {} +attributes: + action: + description: Indicates this has a corresponding action plugin so some parts of the options can be executed on the controller. + async: + description: Supports being used with the C(async) keyword. +''' diff --git a/ansible_collections/community/crypto/plugins/doc_fragments/ecs_credential.py b/ansible_collections/community/crypto/plugins/doc_fragments/ecs_credential.py new file mode 100644 index 00000000..0b6d4037 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/doc_fragments/ecs_credential.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- + +# Copyright (c), Entrust Datacard Corporation, 2019 +# 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 + + +class ModuleDocFragment(object): + + # Plugin options for Entrust Certificate Services (ECS) credentials + DOCUMENTATION = r''' +options: + entrust_api_user: + description: + - The username for authentication to the Entrust Certificate Services (ECS) API. + type: str + required: true + entrust_api_key: + description: + - The key (password) for authentication to the Entrust Certificate Services (ECS) API. + type: str + required: true + entrust_api_client_cert_path: + description: + - The path to the client certificate used to authenticate to the Entrust Certificate Services (ECS) API. + type: path + required: true + entrust_api_client_cert_key_path: + description: + - The path to the key for the client certificate used to authenticate to the Entrust Certificate Services (ECS) API. + type: path + required: true + entrust_api_specification_path: + description: + - The path to the specification file defining the Entrust Certificate Services (ECS) API configuration. + - You can use this to keep a local copy of the specification to avoid downloading it every time the module is used. + type: path + default: https://cloud.entrust.net/EntrustCloud/documentation/cms-api-2.1.0.yaml +requirements: + - "PyYAML >= 3.11" +''' diff --git a/ansible_collections/community/crypto/plugins/doc_fragments/module_certificate.py b/ansible_collections/community/crypto/plugins/doc_fragments/module_certificate.py new file mode 100644 index 00000000..e277edfa --- /dev/null +++ b/ansible_collections/community/crypto/plugins/doc_fragments/module_certificate.py @@ -0,0 +1,404 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2016-2017, Yanis Guenane <yanis+ansible@guenane.org> +# Copyright (c) 2017, Markus Teufelberger <mteufelberger+ansible@mgit.at> +# 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 + + +class ModuleDocFragment(object): + + # Standard files documentation fragment + DOCUMENTATION = r''' +description: + - This module allows one to (re)generate OpenSSL certificates. + - It uses the cryptography python library to interact with OpenSSL. +requirements: + - cryptography >= 1.6 (if using C(selfsigned) or C(ownca) provider) +options: + force: + description: + - Generate the certificate, even if it already exists. + type: bool + default: false + + csr_path: + description: + - Path to the Certificate Signing Request (CSR) used to generate this certificate. + - This is mutually exclusive with I(csr_content). + type: path + csr_content: + description: + - Content of the Certificate Signing Request (CSR) used to generate this certificate. + - This is mutually exclusive with I(csr_path). + type: str + + privatekey_path: + description: + - Path to the private key to use when signing the certificate. + - This is mutually exclusive with I(privatekey_content). + type: path + privatekey_content: + description: + - Path to the private key to use when signing the certificate. + - This is mutually exclusive with I(privatekey_path). + type: str + + privatekey_passphrase: + description: + - The passphrase for the I(privatekey_path) resp. I(privatekey_content). + - This is required if the private key is password protected. + type: str + + ignore_timestamps: + description: + - Whether the "not before" and "not after" timestamps should be ignored for idempotency checks. + - It is better to keep the default value C(true) when using relative timestamps (like C(+0s) for now). + type: bool + default: true + version_added: 2.0.0 + + select_crypto_backend: + description: + - Determines which crypto backend to use. + - The default choice is C(auto), which tries to use C(cryptography) if available. + - If set to C(cryptography), will try to use the L(cryptography,https://cryptography.io/) library. + type: str + default: auto + choices: [ auto, cryptography ] + +notes: + - All ASN.1 TIME values should be specified following the YYYYMMDDHHMMSSZ pattern. + - Date specified should be UTC. Minutes and seconds are mandatory. + - For security reason, when you use C(ownca) provider, you should NOT run + M(community.crypto.x509_certificate) on a target machine, but on a dedicated CA machine. It + is recommended not to store the CA private key on the target machine. Once signed, the + certificate can be moved to the target machine. +seealso: +- module: community.crypto.openssl_csr +- module: community.crypto.openssl_csr_pipe +- module: community.crypto.openssl_dhparam +- module: community.crypto.openssl_pkcs12 +- module: community.crypto.openssl_privatekey +- module: community.crypto.openssl_privatekey_pipe +- module: community.crypto.openssl_publickey +''' + + BACKEND_ACME_DOCUMENTATION = r''' +description: + - This module allows one to (re)generate OpenSSL certificates. +requirements: + - acme-tiny >= 4.0.0 (if using the C(acme) provider) +options: + acme_accountkey_path: + description: + - The path to the accountkey for the C(acme) provider. + - This is only used by the C(acme) provider. + type: path + + acme_challenge_path: + description: + - The path to the ACME challenge directory that is served on U(http://<HOST>:80/.well-known/acme-challenge/) + - This is only used by the C(acme) provider. + type: path + + acme_chain: + description: + - Include the intermediate certificate to the generated certificate + - This is only used by the C(acme) provider. + - Note that this is only available for older versions of C(acme-tiny). + New versions include the chain automatically, and setting I(acme_chain) to C(true) results in an error. + type: bool + default: false + + acme_directory: + description: + - "The ACME directory to use. You can use any directory that supports the ACME protocol, such as Buypass or Let's Encrypt." + - "Let's Encrypt recommends using their staging server while developing jobs. U(https://letsencrypt.org/docs/staging-environment/)." + type: str + default: https://acme-v02.api.letsencrypt.org/directory +''' + + BACKEND_ENTRUST_DOCUMENTATION = r''' +options: + entrust_cert_type: + description: + - Specify the type of certificate requested. + - This is only used by the C(entrust) provider. + type: str + default: STANDARD_SSL + choices: [ 'STANDARD_SSL', 'ADVANTAGE_SSL', 'UC_SSL', 'EV_SSL', 'WILDCARD_SSL', 'PRIVATE_SSL', 'PD_SSL', 'CDS_ENT_LITE', 'CDS_ENT_PRO', 'SMIME_ENT' ] + + entrust_requester_email: + description: + - The email of the requester of the certificate (for tracking purposes). + - This is only used by the C(entrust) provider. + - This is required if the provider is C(entrust). + type: str + + entrust_requester_name: + description: + - The name of the requester of the certificate (for tracking purposes). + - This is only used by the C(entrust) provider. + - This is required if the provider is C(entrust). + type: str + + entrust_requester_phone: + description: + - The phone number of the requester of the certificate (for tracking purposes). + - This is only used by the C(entrust) provider. + - This is required if the provider is C(entrust). + type: str + + entrust_api_user: + description: + - The username for authentication to the Entrust Certificate Services (ECS) API. + - This is only used by the C(entrust) provider. + - This is required if the provider is C(entrust). + type: str + + entrust_api_key: + description: + - The key (password) for authentication to the Entrust Certificate Services (ECS) API. + - This is only used by the C(entrust) provider. + - This is required if the provider is C(entrust). + type: str + + entrust_api_client_cert_path: + description: + - The path to the client certificate used to authenticate to the Entrust Certificate Services (ECS) API. + - This is only used by the C(entrust) provider. + - This is required if the provider is C(entrust). + type: path + + entrust_api_client_cert_key_path: + description: + - The path to the private key of the client certificate used to authenticate to the Entrust Certificate Services (ECS) API. + - This is only used by the C(entrust) provider. + - This is required if the provider is C(entrust). + type: path + + entrust_not_after: + description: + - The point in time at which the certificate stops being valid. + - Time can be specified either as relative time or as an absolute timestamp. + - A valid absolute time format is C(ASN.1 TIME) such as C(2019-06-18). + - A valid relative time format is C([+-]timespec) where timespec can be an integer + C([w | d | h | m | s]), such as C(+365d) or C(+32w1d2h)). + - Time will always be interpreted as UTC. + - Note that only the date (day, month, year) is supported for specifying the expiry date of the issued certificate. + - The full date-time is adjusted to EST (GMT -5:00) before issuance, which may result in a certificate with an expiration date one day + earlier than expected if a relative time is used. + - The minimum certificate lifetime is 90 days, and maximum is three years. + - If this value is not specified, the certificate will stop being valid 365 days the date of issue. + - This is only used by the C(entrust) provider. + - Please note that this value is B(not) covered by the I(ignore_timestamps) option. + type: str + default: +365d + + entrust_api_specification_path: + description: + - The path to the specification file defining the Entrust Certificate Services (ECS) API configuration. + - You can use this to keep a local copy of the specification to avoid downloading it every time the module is used. + - This is only used by the C(entrust) provider. + type: path + default: https://cloud.entrust.net/EntrustCloud/documentation/cms-api-2.1.0.yaml +''' + + BACKEND_OWNCA_DOCUMENTATION = r''' +description: + - The C(ownca) provider is intended for generating an OpenSSL certificate signed with your own + CA (Certificate Authority) certificate (self-signed certificate). +options: + ownca_path: + description: + - Remote absolute path of the CA (Certificate Authority) certificate. + - This is only used by the C(ownca) provider. + - This is mutually exclusive with I(ownca_content). + type: path + ownca_content: + description: + - Content of the CA (Certificate Authority) certificate. + - This is only used by the C(ownca) provider. + - This is mutually exclusive with I(ownca_path). + type: str + + ownca_privatekey_path: + description: + - Path to the CA (Certificate Authority) private key to use when signing the certificate. + - This is only used by the C(ownca) provider. + - This is mutually exclusive with I(ownca_privatekey_content). + type: path + ownca_privatekey_content: + description: + - Content of the CA (Certificate Authority) private key to use when signing the certificate. + - This is only used by the C(ownca) provider. + - This is mutually exclusive with I(ownca_privatekey_path). + type: str + + ownca_privatekey_passphrase: + description: + - The passphrase for the I(ownca_privatekey_path) resp. I(ownca_privatekey_content). + - This is only used by the C(ownca) provider. + type: str + + ownca_digest: + description: + - The digest algorithm to be used for the C(ownca) certificate. + - This is only used by the C(ownca) provider. + type: str + default: sha256 + + ownca_version: + description: + - The version of the C(ownca) certificate. + - Nowadays it should almost always be C(3). + - This is only used by the C(ownca) provider. + type: int + default: 3 + + ownca_not_before: + description: + - The point in time the certificate is valid from. + - 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 C(+32w1d2h)). + - If this value is not specified, the certificate will start being valid from now. + - Note that this value is B(not used to determine whether an existing certificate should be regenerated). + This can be changed by setting the I(ignore_timestamps) option to C(false). Please note that you should + avoid relative timestamps when setting I(ignore_timestamps=false). + - This is only used by the C(ownca) provider. + type: str + default: +0s + + ownca_not_after: + description: + - The point in time at which the certificate stops being valid. + - 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 C(+32w1d2h)). + - If this value is not specified, the certificate will stop being valid 10 years from now. + - Note that this value is B(not used to determine whether an existing certificate should be regenerated). + This can be changed by setting the I(ignore_timestamps) option to C(false). Please note that you should + avoid relative timestamps when setting I(ignore_timestamps=false). + - This is only used by the C(ownca) provider. + - On macOS 10.15 and onwards, TLS server certificates must have a validity period of 825 days or fewer. + Please see U(https://support.apple.com/en-us/HT210176) for more details. + type: str + default: +3650d + + ownca_create_subject_key_identifier: + description: + - Whether to create the Subject Key Identifier (SKI) from the public key. + - A value of C(create_if_not_provided) (default) only creates a SKI when the CSR does not + provide one. + - A value of C(always_create) always creates a SKI. If the CSR provides one, that one is + ignored. + - A value of C(never_create) never creates a SKI. If the CSR provides one, that one is used. + - This is only used by the C(ownca) provider. + - Note that this is only supported if the C(cryptography) backend is used! + type: str + choices: [create_if_not_provided, always_create, never_create] + default: create_if_not_provided + + ownca_create_authority_key_identifier: + description: + - Create a Authority Key Identifier from the CA's certificate. If the CSR provided + a authority key identifier, it is ignored. + - The Authority Key Identifier is generated from the CA certificate's Subject Key Identifier, + if available. If it is not available, the CA certificate's public key will be used. + - This is only used by the C(ownca) provider. + - Note that this is only supported if the C(cryptography) backend is used! + type: bool + default: true +''' + + BACKEND_SELFSIGNED_DOCUMENTATION = r''' +notes: + - For the C(selfsigned) provider, I(csr_path) and I(csr_content) are optional. If not provided, a + certificate without any information (Subject, Subject Alternative Names, Key Usage, etc.) is created. + +options: + # NOTE: descriptions in options are overwritten, not appended. For that reason, the texts provided + # here for csr_path and csr_content are not visible to the user. That's why this information is + # added to the notes (see above). + + # csr_path: + # description: + # - This is optional for the C(selfsigned) provider. If not provided, a certificate + # without any information (Subject, Subject Alternative Names, Key Usage, etc.) is + # created. + + # csr_content: + # description: + # - This is optional for the C(selfsigned) provider. If not provided, a certificate + # without any information (Subject, Subject Alternative Names, Key Usage, etc.) is + # created. + + selfsigned_version: + description: + - Version of the C(selfsigned) certificate. + - Nowadays it should almost always be C(3). + - This is only used by the C(selfsigned) provider. + type: int + default: 3 + + selfsigned_digest: + description: + - Digest algorithm to be used when self-signing the certificate. + - This is only used by the C(selfsigned) provider. + type: str + default: sha256 + + selfsigned_not_before: + description: + - The point in time the certificate is valid from. + - 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 C(+32w1d2h)). + - If this value is not specified, the certificate will start being valid from now. + - Note that this value is B(not used to determine whether an existing certificate should be regenerated). + This can be changed by setting the I(ignore_timestamps) option to C(false). Please note that you should + avoid relative timestamps when setting I(ignore_timestamps=false). + - This is only used by the C(selfsigned) provider. + type: str + default: +0s + aliases: [ selfsigned_notBefore ] + + selfsigned_not_after: + description: + - The point in time at which the certificate stops being valid. + - 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 C(+32w1d2h)). + - If this value is not specified, the certificate will stop being valid 10 years from now. + - Note that this value is B(not used to determine whether an existing certificate should be regenerated). + This can be changed by setting the I(ignore_timestamps) option to C(false). Please note that you should + avoid relative timestamps when setting I(ignore_timestamps=false). + - This is only used by the C(selfsigned) provider. + - On macOS 10.15 and onwards, TLS server certificates must have a validity period of 825 days or fewer. + Please see U(https://support.apple.com/en-us/HT210176) for more details. + type: str + default: +3650d + aliases: [ selfsigned_notAfter ] + + selfsigned_create_subject_key_identifier: + description: + - Whether to create the Subject Key Identifier (SKI) from the public key. + - A value of C(create_if_not_provided) (default) only creates a SKI when the CSR does not + provide one. + - A value of C(always_create) always creates a SKI. If the CSR provides one, that one is + ignored. + - A value of C(never_create) never creates a SKI. If the CSR provides one, that one is used. + - This is only used by the C(selfsigned) provider. + - Note that this is only supported if the C(cryptography) backend is used! + type: str + choices: [create_if_not_provided, always_create, never_create] + default: create_if_not_provided +''' diff --git a/ansible_collections/community/crypto/plugins/doc_fragments/module_csr.py b/ansible_collections/community/crypto/plugins/doc_fragments/module_csr.py new file mode 100644 index 00000000..81c4318a --- /dev/null +++ b/ansible_collections/community/crypto/plugins/doc_fragments/module_csr.py @@ -0,0 +1,325 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2017, Yanis Guenane <yanis+ansible@guenane.org> +# 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 + + +class ModuleDocFragment(object): + + # Standard files documentation fragment + DOCUMENTATION = r''' +description: + - This module allows one to (re)generate OpenSSL certificate signing requests. + - This module supports the subjectAltName, keyUsage, extendedKeyUsage, basicConstraints and OCSP Must Staple + extensions. +requirements: + - cryptography >= 1.3 +options: + digest: + description: + - The digest used when signing the certificate signing request with the private key. + type: str + default: sha256 + privatekey_path: + description: + - The path to the private key to use when signing the certificate signing request. + - Either I(privatekey_path) or I(privatekey_content) must be specified if I(state) is C(present), but not both. + type: path + privatekey_content: + description: + - The content of the private key to use when signing the certificate signing request. + - Either I(privatekey_path) or I(privatekey_content) must be specified if I(state) is C(present), but not both. + type: str + privatekey_passphrase: + description: + - The passphrase for the private key. + - This is required if the private key is password protected. + type: str + version: + description: + - The version of the certificate signing request. + - "The only allowed value according to L(RFC 2986,https://tools.ietf.org/html/rfc2986#section-4.1) + is 1." + - This option no longer accepts unsupported values since community.crypto 2.0.0. + type: int + default: 1 + choices: + - 1 + subject: + description: + - Key/value pairs that will be present in the subject name field of the certificate signing request. + - If you need to specify more than one value with the same key, use a list as value. + - If the order of the components is important, use I(subject_ordered). + - Mutually exclusive with I(subject_ordered). + type: dict + subject_ordered: + description: + - A list of dictionaries, where every dictionary must contain one key/value pair. This key/value pair + will be present in the subject name field of the certificate signing request. + - If you want to specify more than one value with the same key in a row, you can use a list as value. + - Mutually exclusive with I(subject), and any other subject field option, such as I(country_name), + I(state_or_province_name), I(locality_name), I(organization_name), I(organizational_unit_name), + I(common_name), or I(email_address). + type: list + elements: dict + version_added: 2.0.0 + country_name: + description: + - The countryName field of the certificate signing request subject. + type: str + aliases: [ C, countryName ] + state_or_province_name: + description: + - The stateOrProvinceName field of the certificate signing request subject. + type: str + aliases: [ ST, stateOrProvinceName ] + locality_name: + description: + - The localityName field of the certificate signing request subject. + type: str + aliases: [ L, localityName ] + organization_name: + description: + - The organizationName field of the certificate signing request subject. + type: str + aliases: [ O, organizationName ] + organizational_unit_name: + description: + - The organizationalUnitName field of the certificate signing request subject. + type: str + aliases: [ OU, organizationalUnitName ] + common_name: + description: + - The commonName field of the certificate signing request subject. + type: str + aliases: [ CN, commonName ] + email_address: + description: + - The emailAddress field of the certificate signing request subject. + type: str + aliases: [ E, emailAddress ] + subject_alt_name: + description: + - Subject Alternative Name (SAN) extension to attach to the certificate signing request. + - Values must be prefixed by their options. (These are C(email), C(URI), C(DNS), C(RID), C(IP), C(dirName), + C(otherName), and the ones specific to your CA). + - Note that if no SAN is specified, but a common name, the common + name will be added as a SAN except if C(useCommonNameForSAN) is + set to I(false). + - More at U(https://tools.ietf.org/html/rfc5280#section-4.2.1.6). + type: list + elements: str + aliases: [ subjectAltName ] + subject_alt_name_critical: + description: + - Should the subjectAltName extension be considered as critical. + type: bool + default: false + aliases: [ subjectAltName_critical ] + use_common_name_for_san: + description: + - If set to C(true), the module will fill the common name in for + C(subject_alt_name) with C(DNS:) prefix if no SAN is specified. + type: bool + default: true + aliases: [ useCommonNameForSAN ] + key_usage: + description: + - This defines the purpose (for example encipherment, signature, certificate signing) + of the key contained in the certificate. + type: list + elements: str + aliases: [ keyUsage ] + key_usage_critical: + description: + - Should the keyUsage extension be considered as critical. + type: bool + default: false + aliases: [ keyUsage_critical ] + extended_key_usage: + description: + - Additional restrictions (for example client authentication, server authentication) + on the allowed purposes for which the public key may be used. + type: list + elements: str + aliases: [ extKeyUsage, extendedKeyUsage ] + extended_key_usage_critical: + description: + - Should the extkeyUsage extension be considered as critical. + type: bool + default: false + aliases: [ extKeyUsage_critical, extendedKeyUsage_critical ] + basic_constraints: + description: + - Indicates basic constraints, such as if the certificate is a CA. + type: list + elements: str + aliases: [ basicConstraints ] + basic_constraints_critical: + description: + - Should the basicConstraints extension be considered as critical. + type: bool + default: false + aliases: [ basicConstraints_critical ] + ocsp_must_staple: + description: + - Indicates that the certificate should contain the OCSP Must Staple + extension (U(https://tools.ietf.org/html/rfc7633)). + type: bool + default: false + aliases: [ ocspMustStaple ] + ocsp_must_staple_critical: + description: + - Should the OCSP Must Staple extension be considered as critical. + - Note that according to the RFC, this extension should not be marked + as critical, as old clients not knowing about OCSP Must Staple + are required to reject such certificates + (see U(https://tools.ietf.org/html/rfc7633#section-4)). + type: bool + default: false + aliases: [ ocspMustStaple_critical ] + name_constraints_permitted: + description: + - For CA certificates, this specifies a list of identifiers which describe + subtrees of names that this CA is allowed to issue certificates for. + - Values must be prefixed by their options. (i.e., C(email), C(URI), C(DNS), C(RID), C(IP), C(dirName), + C(otherName) and the ones specific to your CA). + type: list + elements: str + name_constraints_excluded: + description: + - For CA certificates, this specifies a list of identifiers which describe + subtrees of names that this CA is B(not) allowed to issue certificates for. + - Values must be prefixed by their options. (i.e., C(email), C(URI), C(DNS), C(RID), C(IP), C(dirName), + C(otherName) and the ones specific to your CA). + type: list + elements: str + name_constraints_critical: + description: + - Should the Name Constraints extension be considered as critical. + type: bool + default: false + select_crypto_backend: + description: + - Determines which crypto backend to use. + - The default choice is C(auto), which tries to use C(cryptography) if available. + - If set to C(cryptography), will try to use the L(cryptography,https://cryptography.io/) library. + type: str + default: auto + choices: [ auto, cryptography ] + create_subject_key_identifier: + description: + - Create the Subject Key Identifier from the public key. + - "Please note that commercial CAs can ignore the value, respectively use a value of + their own choice instead. Specifying this option is mostly useful for self-signed + certificates or for own CAs." + - Note that this is only supported if the C(cryptography) backend is used! + type: bool + default: false + subject_key_identifier: + description: + - The subject key identifier as a hex string, where two bytes are separated by colons. + - "Example: C(00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff:00:11:22:33)" + - "Please note that commercial CAs ignore this value, respectively use a value of their + own choice. Specifying this option is mostly useful for self-signed certificates + or for own CAs." + - Note that this option can only be used if I(create_subject_key_identifier) is C(false). + - Note that this is only supported if the C(cryptography) backend is used! + type: str + authority_key_identifier: + description: + - The authority key identifier as a hex string, where two bytes are separated by colons. + - "Example: C(00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff:00:11:22:33)" + - "Please note that commercial CAs ignore this value, respectively use a value of their + own choice. Specifying this option is mostly useful for self-signed certificates + or for own CAs." + - Note that this is only supported if the C(cryptography) backend is used! + - The C(AuthorityKeyIdentifier) extension will only be added if at least one of I(authority_key_identifier), + I(authority_cert_issuer) and I(authority_cert_serial_number) is specified. + type: str + authority_cert_issuer: + description: + - Names that will be present in the authority cert issuer field of the certificate signing request. + - Values must be prefixed by their options. (i.e., C(email), C(URI), C(DNS), C(RID), C(IP), C(dirName), + C(otherName) and the ones specific to your CA) + - "Example: C(DNS:ca.example.org)" + - If specified, I(authority_cert_serial_number) must also be specified. + - "Please note that commercial CAs ignore this value, respectively use a value of their + own choice. Specifying this option is mostly useful for self-signed certificates + or for own CAs." + - Note that this is only supported if the C(cryptography) backend is used! + - The C(AuthorityKeyIdentifier) extension will only be added if at least one of I(authority_key_identifier), + I(authority_cert_issuer) and I(authority_cert_serial_number) is specified. + type: list + elements: str + authority_cert_serial_number: + description: + - The authority cert serial number. + - If specified, I(authority_cert_issuer) must also be specified. + - Note that this is only supported if the C(cryptography) backend is used! + - "Please note that commercial CAs ignore this value, respectively use a value of their + own choice. Specifying this option is mostly useful for self-signed certificates + or for own CAs." + - The C(AuthorityKeyIdentifier) extension will only be added if at least one of I(authority_key_identifier), + I(authority_cert_issuer) and I(authority_cert_serial_number) is specified. + type: int + crl_distribution_points: + description: + - Allows to specify one or multiple CRL distribution points. + - Only supported by the C(cryptography) backend. + type: list + elements: dict + suboptions: + full_name: + description: + - Describes how the CRL can be retrieved. + - Mutually exclusive with I(relative_name). + - "Example: C(URI:https://ca.example.com/revocations.crl)." + type: list + elements: str + relative_name: + description: + - Describes how the CRL can be retrieved relative to the CRL issuer. + - Mutually exclusive with I(full_name). + - "Example: C(/CN=example.com)." + - Can only be used when cryptography >= 1.6 is installed. + type: list + elements: str + crl_issuer: + description: + - Information about the issuer of the CRL. + type: list + elements: str + reasons: + description: + - List of reasons that this distribution point can be used for when performing revocation checks. + type: list + elements: str + choices: + - key_compromise + - ca_compromise + - affiliation_changed + - superseded + - cessation_of_operation + - certificate_hold + - privilege_withdrawn + - aa_compromise + version_added: 1.4.0 +notes: + - If the certificate signing request already exists it will be checked whether subjectAltName, + keyUsage, extendedKeyUsage and basicConstraints only contain the requested values, whether + OCSP Must Staple is as requested, and if the request was signed by the given private key. +seealso: +- module: community.crypto.x509_certificate +- module: community.crypto.x509_certificate_pipe +- module: community.crypto.openssl_dhparam +- module: community.crypto.openssl_pkcs12 +- module: community.crypto.openssl_privatekey +- module: community.crypto.openssl_privatekey_pipe +- module: community.crypto.openssl_publickey +- module: community.crypto.openssl_csr_info +''' diff --git a/ansible_collections/community/crypto/plugins/doc_fragments/module_privatekey.py b/ansible_collections/community/crypto/plugins/doc_fragments/module_privatekey.py new file mode 100644 index 00000000..a27b26c7 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/doc_fragments/module_privatekey.py @@ -0,0 +1,151 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2016, Yanis Guenane <yanis+ansible@guenane.org> +# 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 + + +class ModuleDocFragment(object): + + # Standard files documentation fragment + DOCUMENTATION = r''' +description: + - One can generate L(RSA,https://en.wikipedia.org/wiki/RSA_%28cryptosystem%29), + L(DSA,https://en.wikipedia.org/wiki/Digital_Signature_Algorithm), + L(ECC,https://en.wikipedia.org/wiki/Elliptic-curve_cryptography) or + L(EdDSA,https://en.wikipedia.org/wiki/EdDSA) private keys. + - Keys are generated in PEM format. + - "Please note that the module regenerates private keys if they do not match + the module's options. In particular, if you provide another passphrase + (or specify none), change the keysize, etc., the private key will be + regenerated. If you are concerned that this could B(overwrite your private key), + consider using the I(backup) option." +requirements: + - cryptography >= 1.2.3 (older versions might work as well) +options: + size: + description: + - Size (in bits) of the TLS/SSL key to generate. + type: int + default: 4096 + type: + description: + - The algorithm used to generate the TLS/SSL private key. + - Note that C(ECC), C(X25519), C(X448), C(Ed25519) and C(Ed448) require the C(cryptography) backend. + C(X25519) needs cryptography 2.5 or newer, while C(X448), C(Ed25519) and C(Ed448) require + cryptography 2.6 or newer. For C(ECC), the minimal cryptography version required depends on the + I(curve) option. + type: str + default: RSA + choices: [ DSA, ECC, Ed25519, Ed448, RSA, X25519, X448 ] + curve: + description: + - Note that not all curves are supported by all versions of C(cryptography). + - For maximal interoperability, C(secp384r1) or C(secp256r1) should be used. + - We use the curve names as defined in the + L(IANA registry for TLS,https://www.iana.org/assignments/tls-parameters/tls-parameters.xhtml#tls-parameters-8). + - Please note that all curves except C(secp224r1), C(secp256k1), C(secp256r1), C(secp384r1) and C(secp521r1) + are discouraged for new private keys. + type: str + choices: + - secp224r1 + - secp256k1 + - secp256r1 + - secp384r1 + - secp521r1 + - secp192r1 + - brainpoolP256r1 + - brainpoolP384r1 + - brainpoolP512r1 + - sect163k1 + - sect163r2 + - sect233k1 + - sect233r1 + - sect283k1 + - sect283r1 + - sect409k1 + - sect409r1 + - sect571k1 + - sect571r1 + passphrase: + description: + - The passphrase for the private key. + type: str + cipher: + description: + - The cipher to encrypt the private key. Must be C(auto). + type: str + select_crypto_backend: + description: + - Determines which crypto backend to use. + - The default choice is C(auto), which tries to use C(cryptography) if available. + - If set to C(cryptography), will try to use the L(cryptography,https://cryptography.io/) library. + type: str + default: auto + choices: [ auto, cryptography ] + format: + description: + - Determines which format the private key is written in. By default, PKCS1 (traditional OpenSSL format) + is used for all keys which support it. Please note that not every key can be exported in any format. + - The value C(auto) selects a format based on the key format. The value C(auto_ignore) does the same, + but for existing private key files, it will not force a regenerate when its format is not the automatically + selected one for generation. + - Note that if the format for an existing private key mismatches, the key is B(regenerated) by default. + To change this behavior, use the I(format_mismatch) option. + type: str + default: auto_ignore + choices: [ pkcs1, pkcs8, raw, auto, auto_ignore ] + format_mismatch: + description: + - Determines behavior of the module if the format of a private key does not match the expected format, but all + other parameters are as expected. + - If set to C(regenerate) (default), generates a new private key. + - If set to C(convert), the key will be converted to the new format instead. + - Only supported by the C(cryptography) backend. + type: str + default: regenerate + choices: [ regenerate, convert ] + regenerate: + description: + - Allows to configure in which situations the module is allowed to regenerate private keys. + The module will always generate a new key if the destination file does not exist. + - By default, the key will be regenerated when it does not match the module's options, + except when the key cannot be read or the passphrase does not match. Please note that + this B(changed) for Ansible 2.10. For Ansible 2.9, the behavior was as if C(full_idempotence) + is specified. + - If set to C(never), the module will fail if the key cannot be read or the passphrase + is not matching, and will never regenerate an existing key. + - If set to C(fail), the module will fail if the key does not correspond to the module's + options. + - If set to C(partial_idempotence), the key will be regenerated if it does not conform to + the module's options. The key is B(not) regenerated if it cannot be read (broken file), + the key is protected by an unknown passphrase, or when they key is not protected by a + passphrase, but a passphrase is specified. + - If set to C(full_idempotence), the key will be regenerated if it does not conform to the + module's options. This is also the case if the key cannot be read (broken file), the key + is protected by an unknown passphrase, or when they key is not protected by a passphrase, + but a passphrase is specified. Make sure you have a B(backup) when using this option! + - If set to C(always), the module will always regenerate the key. This is equivalent to + setting I(force) to C(true). + - Note that if I(format_mismatch) is set to C(convert) and everything matches except the + format, the key will always be converted, except if I(regenerate) is set to C(always). + type: str + choices: + - never + - fail + - partial_idempotence + - full_idempotence + - always + default: full_idempotence +seealso: +- module: community.crypto.x509_certificate +- module: community.crypto.x509_certificate_pipe +- module: community.crypto.openssl_csr +- module: community.crypto.openssl_csr_pipe +- module: community.crypto.openssl_dhparam +- module: community.crypto.openssl_pkcs12 +- module: community.crypto.openssl_publickey +''' diff --git a/ansible_collections/community/crypto/plugins/doc_fragments/module_privatekey_convert.py b/ansible_collections/community/crypto/plugins/doc_fragments/module_privatekey_convert.py new file mode 100644 index 00000000..f1c6f70e --- /dev/null +++ b/ansible_collections/community/crypto/plugins/doc_fragments/module_privatekey_convert.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2022, 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 + + +class ModuleDocFragment(object): + + # Standard files documentation fragment + DOCUMENTATION = r''' +requirements: + - cryptography >= 1.2.3 (older versions might work as well) +options: + src_path: + description: + - Name of the file containing the OpenSSL private key to convert. + - Exactly one of I(src_path) or I(src_content) must be specified. + type: path + src_content: + description: + - The content of the file containing the OpenSSL private key to convert. + - Exactly one of I(src_path) or I(src_content) must be specified. + type: str + src_passphrase: + description: + - The passphrase for the private key to load. + type: str + dest_passphrase: + description: + - The passphrase for the private key to store. + type: str + format: + description: + - Determines which format the destination private key should be written in. + - Please note that not every key can be exported in any format, and that not every + format supports encryption. + type: str + choices: [ pkcs1, pkcs8, raw ] + required: true +seealso: + - module: community.crypto.openssl_privatekey + - module: community.crypto.openssl_privatekey_pipe + - module: community.crypto.openssl_publickey +''' diff --git a/ansible_collections/community/crypto/plugins/doc_fragments/name_encoding.py b/ansible_collections/community/crypto/plugins/doc_fragments/name_encoding.py new file mode 100644 index 00000000..fec94380 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/doc_fragments/name_encoding.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2022, 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 + + +class ModuleDocFragment(object): + DOCUMENTATION = r''' +options: + name_encoding: + description: + - How to encode names (DNS names, URIs, email addresses) in return values. + - C(ignore) will use the encoding returned by the backend. + - C(idna) will convert all labels of domain names to IDNA encoding. + IDNA2008 will be preferred, and IDNA2003 will be used if IDNA2008 encoding fails. + - C(unicode) will convert all labels of domain names to Unicode. + IDNA2008 will be preferred, and IDNA2003 will be used if IDNA2008 decoding fails. + - B(Note) that C(idna) and C(unicode) require the L(idna Python library,https://pypi.org/project/idna/) to be installed. + type: str + default: ignore + choices: + - ignore + - idna + - unicode +requirements: + - If I(name_encoding) is set to another value than C(ignore), the L(idna Python library,https://pypi.org/project/idna/) needs to be installed. +''' diff --git a/ansible_collections/community/crypto/plugins/filter/openssl_csr_info.py b/ansible_collections/community/crypto/plugins/filter/openssl_csr_info.py new file mode 100644 index 00000000..851dfe2a --- /dev/null +++ b/ansible_collections/community/crypto/plugins/filter/openssl_csr_info.py @@ -0,0 +1,313 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2022, 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 = ''' +name: openssl_csr_info +short_description: Retrieve information from OpenSSL Certificate Signing Requests (CSR) +version_added: 2.10.0 +author: + - Felix Fontein (@felixfontein) +description: + - Provided an OpenSSL Certificate Signing Requests (CSR), retrieve information. + - This is a filter version of the M(community.crypto.openssl_csr_info) module. +options: + _input: + description: + - The content of the OpenSSL CSR. + type: string + required: true +extends_documentation_fragment: + - community.crypto.name_encoding +seealso: + - module: community.crypto.openssl_csr_info +''' + +EXAMPLES = ''' +- name: Show the Subject Alt Names of the CSR + ansible.builtin.debug: + msg: >- + {{ + ( + lookup('ansible.builtin.file', '/path/to/cert.csr') + | community.crypto.openssl_csr_info + ).subject_alt_name | join(', ') + }} +''' + +RETURN = ''' +_value: + description: + - Information on the certificate. + type: dict + contains: + signature_valid: + description: + - Whether the CSR's signature is valid. + - In case the check returns C(false), the module will fail. + returned: success + type: bool + basic_constraints: + description: Entries in the C(basic_constraints) extension, or C(none) if extension is not present. + returned: success + type: list + elements: str + sample: ['CA:TRUE', 'pathlen:1'] + basic_constraints_critical: + description: Whether the C(basic_constraints) extension is critical. + returned: success + type: bool + extended_key_usage: + description: Entries in the C(extended_key_usage) extension, or C(none) if extension is not present. + returned: success + type: list + elements: str + sample: [Biometric Info, DVCS, Time Stamping] + extended_key_usage_critical: + description: Whether the C(extended_key_usage) extension is critical. + returned: success + type: bool + extensions_by_oid: + description: Returns a dictionary for every extension OID + returned: success + type: dict + contains: + critical: + description: Whether the extension is critical. + returned: success + type: bool + value: + description: + - The Base64 encoded value (in DER format) of the extension. + - B(Note) that depending on the C(cryptography) version used, it is + not possible to extract the ASN.1 content of the extension, but only + to provide the re-encoded content of the extension in case it was + parsed by C(cryptography). This should usually result in exactly the + same value, except if the original extension value was malformed. + returned: success + type: str + sample: "MAMCAQU=" + sample: {"1.3.6.1.5.5.7.1.24": { "critical": false, "value": "MAMCAQU="}} + key_usage: + description: Entries in the C(key_usage) extension, or C(none) if extension is not present. + returned: success + type: str + sample: [Key Agreement, Data Encipherment] + key_usage_critical: + description: Whether the C(key_usage) extension is critical. + returned: success + type: bool + subject_alt_name: + description: + - Entries in the C(subject_alt_name) extension, or C(none) if extension is not present. + - See I(name_encoding) for how IDNs are handled. + returned: success + type: list + elements: str + sample: ["DNS:www.ansible.com", "IP:1.2.3.4"] + subject_alt_name_critical: + description: Whether the C(subject_alt_name) extension is critical. + returned: success + type: bool + ocsp_must_staple: + description: C(true) if the OCSP Must Staple extension is present, C(none) otherwise. + returned: success + type: bool + ocsp_must_staple_critical: + description: Whether the C(ocsp_must_staple) extension is critical. + returned: success + type: bool + name_constraints_permitted: + description: List of permitted subtrees to sign certificates for. + returned: success + type: list + elements: str + sample: ['email:.somedomain.com'] + name_constraints_excluded: + description: + - List of excluded subtrees the CA cannot sign certificates for. + - Is C(none) if extension is not present. + - See I(name_encoding) for how IDNs are handled. + returned: success + type: list + elements: str + sample: ['email:.com'] + name_constraints_critical: + description: + - Whether the C(name_constraints) extension is critical. + - Is C(none) if extension is not present. + returned: success + type: bool + subject: + description: + - The CSR's subject as a dictionary. + - Note that for repeated values, only the last one will be returned. + returned: success + type: dict + sample: {"commonName": "www.example.com", "emailAddress": "test@example.com"} + subject_ordered: + description: The CSR's subject as an ordered list of tuples. + returned: success + type: list + elements: list + sample: [["commonName", "www.example.com"], ["emailAddress": "test@example.com"]] + public_key: + description: CSR's public key in PEM format + returned: success + type: str + sample: "-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8A..." + public_key_type: + description: + - The CSR's public key's type. + - One of C(RSA), C(DSA), C(ECC), C(Ed25519), C(X25519), C(Ed448), or C(X448). + - Will start with C(unknown) if the key type cannot be determined. + returned: success + type: str + sample: RSA + public_key_data: + description: + - Public key data. Depends on the public key's type. + returned: success + type: dict + contains: + size: + description: + - Bit size of modulus (RSA) or prime number (DSA). + type: int + returned: When C(public_key_type=RSA) or C(public_key_type=DSA) + modulus: + description: + - The RSA key's modulus. + type: int + returned: When C(public_key_type=RSA) + exponent: + description: + - The RSA key's public exponent. + type: int + returned: When C(public_key_type=RSA) + p: + description: + - The C(p) value for DSA. + - This is the prime modulus upon which arithmetic takes place. + type: int + returned: When C(public_key_type=DSA) + q: + description: + - The C(q) value for DSA. + - This is a prime that divides C(p - 1), and at the same time the order of the subgroup of the + multiplicative group of the prime field used. + type: int + returned: When C(public_key_type=DSA) + g: + description: + - The C(g) value for DSA. + - This is the element spanning the subgroup of the multiplicative group of the prime field used. + type: int + returned: When C(public_key_type=DSA) + curve: + description: + - The curve's name for ECC. + type: str + returned: When C(public_key_type=ECC) + exponent_size: + description: + - The maximum number of bits of a private key. This is basically the bit size of the subgroup used. + type: int + returned: When C(public_key_type=ECC) + x: + description: + - The C(x) coordinate for the public point on the elliptic curve. + type: int + returned: When C(public_key_type=ECC) + y: + description: + - For C(public_key_type=ECC), this is the C(y) coordinate for the public point on the elliptic curve. + - For C(public_key_type=DSA), this is the publicly known group element whose discrete logarithm w.r.t. C(g) is the private key. + type: int + returned: When C(public_key_type=DSA) or C(public_key_type=ECC) + public_key_fingerprints: + description: + - Fingerprints of CSR's public key. + - For every hash algorithm available, the fingerprint is computed. + returned: success + type: dict + sample: "{'sha256': 'd4:b3:aa:6d:c8:04:ce:4e:ba:f6:29:4d:92:a3:94:b0:c2:ff:bd:bf:33:63:11:43:34:0f:51:b0:95:09:2f:63', + 'sha512': 'f7:07:4a:f0:b0:f0:e6:8b:95:5f:f9:e6:61:0a:32:68:f1..." + subject_key_identifier: + description: + - The CSR's subject key identifier. + - The identifier is returned in hexadecimal, with C(:) used to separate bytes. + - Is C(none) if the C(SubjectKeyIdentifier) extension is not present. + returned: success + type: str + sample: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff:00:11:22:33' + authority_key_identifier: + description: + - The CSR's authority key identifier. + - The identifier is returned in hexadecimal, with C(:) used to separate bytes. + - Is C(none) if the C(AuthorityKeyIdentifier) extension is not present. + returned: success + type: str + sample: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff:00:11:22:33' + authority_cert_issuer: + description: + - The CSR's authority cert issuer as a list of general names. + - Is C(none) if the C(AuthorityKeyIdentifier) extension is not present. + - See I(name_encoding) for how IDNs are handled. + returned: success + type: list + elements: str + sample: ["DNS:www.ansible.com", "IP:1.2.3.4"] + authority_cert_serial_number: + description: + - The CSR's authority cert serial number. + - Is C(none) if the C(AuthorityKeyIdentifier) extension is not present. + returned: success + type: int + sample: 12345 +''' + +from ansible.errors import AnsibleFilterError +from ansible.module_utils.six import string_types +from ansible.module_utils.common.text.converters import to_bytes, to_native + +from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import ( + OpenSSLObjectError, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.csr_info import ( + get_csr_info, +) + +from ansible_collections.community.crypto.plugins.plugin_utils.filter_module import FilterModuleMock + + +def openssl_csr_info_filter(data, name_encoding='ignore'): + '''Extract information from X.509 PEM certificate.''' + if not isinstance(data, string_types): + raise AnsibleFilterError('The community.crypto.openssl_csr_info input must be a text type, not %s' % type(data)) + if not isinstance(name_encoding, string_types): + raise AnsibleFilterError('The name_encoding option must be of a text type, not %s' % type(name_encoding)) + name_encoding = to_native(name_encoding) + if name_encoding not in ('ignore', 'idna', 'unicode'): + raise AnsibleFilterError('The name_encoding option must be one of the values "ignore", "idna", or "unicode", not "%s"' % name_encoding) + + module = FilterModuleMock({'name_encoding': name_encoding}) + try: + return get_csr_info(module, 'cryptography', content=to_bytes(data), validate_signature=True) + except OpenSSLObjectError as exc: + raise AnsibleFilterError(to_native(exc)) + + +class FilterModule(object): + '''Ansible jinja2 filters''' + + def filters(self): + return { + 'openssl_csr_info': openssl_csr_info_filter, + } diff --git a/ansible_collections/community/crypto/plugins/filter/openssl_privatekey_info.py b/ansible_collections/community/crypto/plugins/filter/openssl_privatekey_info.py new file mode 100644 index 00000000..16dfd859 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/filter/openssl_privatekey_info.py @@ -0,0 +1,193 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2022, 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 = ''' +name: openssl_privatekey_info +short_description: Retrieve information from OpenSSL private keys +version_added: 2.10.0 +author: + - Felix Fontein (@felixfontein) +description: + - Provided an OpenSSL private keys, retrieve information. + - This is a filter version of the M(community.crypto.openssl_privatekey_info) module. +options: + _input: + description: + - The content of the OpenSSL private key. + type: string + required: true + passphrase: + description: + - The passphrase for the private key. + type: str + return_private_key_data: + description: + - Whether to return private key data. + - Only set this to C(true) when you want private information about this key to + be extracted. + - "B(WARNING:) you have to make sure that private key data is not accidentally logged!" + type: bool + default: false +extends_documentation_fragment: + - community.crypto.name_encoding +seealso: + - module: community.crypto.openssl_privatekey_info +''' + +EXAMPLES = ''' +- name: Show the Subject Alt Names of the CSR + ansible.builtin.debug: + msg: >- + {{ + ( + lookup('ansible.builtin.file', '/path/to/cert.csr') + | community.crypto.openssl_privatekey_info + ).subject_alt_name | join(', ') + }} +''' + +RETURN = ''' +_value: + description: + - Information on the certificate. + type: dict + contains: + public_key: + description: Private key's public key in PEM format. + returned: success + type: str + sample: "-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8A..." + public_key_fingerprints: + description: + - Fingerprints of private key's public key. + - For every hash algorithm available, the fingerprint is computed. + returned: success + type: dict + sample: "{'sha256': 'd4:b3:aa:6d:c8:04:ce:4e:ba:f6:29:4d:92:a3:94:b0:c2:ff:bd:bf:33:63:11:43:34:0f:51:b0:95:09:2f:63', + 'sha512': 'f7:07:4a:f0:b0:f0:e6:8b:95:5f:f9:e6:61:0a:32:68:f1..." + type: + description: + - The key's type. + - One of C(RSA), C(DSA), C(ECC), C(Ed25519), C(X25519), C(Ed448), or C(X448). + - Will start with C(unknown) if the key type cannot be determined. + returned: success + type: str + sample: RSA + public_data: + description: + - Public key data. Depends on key type. + returned: success + type: dict + contains: + size: + description: + - Bit size of modulus (RSA) or prime number (DSA). + type: int + returned: When C(type=RSA) or C(type=DSA) + modulus: + description: + - The RSA key's modulus. + type: int + returned: When C(type=RSA) + exponent: + description: + - The RSA key's public exponent. + type: int + returned: When C(type=RSA) + p: + description: + - The C(p) value for DSA. + - This is the prime modulus upon which arithmetic takes place. + type: int + returned: When C(type=DSA) + q: + description: + - The C(q) value for DSA. + - This is a prime that divides C(p - 1), and at the same time the order of the subgroup of the + multiplicative group of the prime field used. + type: int + returned: When C(type=DSA) + g: + description: + - The C(g) value for DSA. + - This is the element spanning the subgroup of the multiplicative group of the prime field used. + type: int + returned: When C(type=DSA) + curve: + description: + - The curve's name for ECC. + type: str + returned: When C(type=ECC) + exponent_size: + description: + - The maximum number of bits of a private key. This is basically the bit size of the subgroup used. + type: int + returned: When C(type=ECC) + x: + description: + - The C(x) coordinate for the public point on the elliptic curve. + type: int + returned: When C(type=ECC) + y: + description: + - For C(type=ECC), this is the C(y) coordinate for the public point on the elliptic curve. + - For C(type=DSA), this is the publicly known group element whose discrete logarithm w.r.t. C(g) is the private key. + type: int + returned: When C(type=DSA) or C(type=ECC) + private_data: + description: + - Private key data. Depends on key type. + returned: success and when I(return_private_key_data) is set to C(true) + type: dict +''' + +from ansible.errors import AnsibleFilterError +from ansible.module_utils.six import string_types +from ansible.module_utils.common.text.converters import to_bytes, to_native + +from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import ( + OpenSSLObjectError, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.privatekey_info import ( + PrivateKeyParseError, + get_privatekey_info, +) + +from ansible_collections.community.crypto.plugins.plugin_utils.filter_module import FilterModuleMock + + +def openssl_privatekey_info_filter(data, passphrase=None, return_private_key_data=False): + '''Extract information from X.509 PEM certificate.''' + if not isinstance(data, string_types): + raise AnsibleFilterError('The community.crypto.openssl_privatekey_info input must be a text type, not %s' % type(data)) + if passphrase is not None and not isinstance(passphrase, string_types): + raise AnsibleFilterError('The passphrase option must be a text type, not %s' % type(passphrase)) + if not isinstance(return_private_key_data, bool): + raise AnsibleFilterError('The return_private_key_data option must be a boolean, not %s' % type(return_private_key_data)) + + module = FilterModuleMock({}) + try: + result = get_privatekey_info(module, 'cryptography', content=to_bytes(data), passphrase=passphrase, return_private_key_data=return_private_key_data) + result.pop('can_parse_key', None) + result.pop('key_is_consistent', None) + return result + except PrivateKeyParseError as exc: + raise AnsibleFilterError(exc.error_message) + except OpenSSLObjectError as exc: + raise AnsibleFilterError(to_native(exc)) + + +class FilterModule(object): + '''Ansible jinja2 filters''' + + def filters(self): + return { + 'openssl_privatekey_info': openssl_privatekey_info_filter, + } diff --git a/ansible_collections/community/crypto/plugins/filter/openssl_publickey_info.py b/ansible_collections/community/crypto/plugins/filter/openssl_publickey_info.py new file mode 100644 index 00000000..f41af1c7 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/filter/openssl_publickey_info.py @@ -0,0 +1,162 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2022, 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 = ''' +name: openssl_publickey_info +short_description: Retrieve information from OpenSSL public keys in PEM format +version_added: 2.10.0 +author: + - Felix Fontein (@felixfontein) +description: + - Provided a public key in OpenSSL PEM format, retrieve information. + - This is a filter version of the M(community.crypto.openssl_publickey_info) module. +options: + _input: + description: + - The content of the OpenSSL PEM public key. + type: string + required: true +seealso: + - module: community.crypto.openssl_publickey_info +''' + +EXAMPLES = ''' +- name: Show the type of a public key + ansible.builtin.debug: + msg: >- + {{ + ( + lookup('ansible.builtin.file', '/path/to/public-key.pem') + | community.crypto.openssl_publickey_info + ).type + }} +''' + +RETURN = ''' +_value: + description: + - Information on the public key. + type: dict + contains: + fingerprints: + description: + - Fingerprints of public key. + - For every hash algorithm available, the fingerprint is computed. + returned: success + type: dict + sample: "{'sha256': 'd4:b3:aa:6d:c8:04:ce:4e:ba:f6:29:4d:92:a3:94:b0:c2:ff:bd:bf:33:63:11:43:34:0f:51:b0:95:09:2f:63', + 'sha512': 'f7:07:4a:f0:b0:f0:e6:8b:95:5f:f9:e6:61:0a:32:68:f1..." + type: + description: + - The key's type. + - One of C(RSA), C(DSA), C(ECC), C(Ed25519), C(X25519), C(Ed448), or C(X448). + - Will start with C(unknown) if the key type cannot be determined. + returned: success + type: str + sample: RSA + public_data: + description: + - Public key data. Depends on key type. + returned: success + type: dict + contains: + size: + description: + - Bit size of modulus (RSA) or prime number (DSA). + type: int + returned: When C(type=RSA) or C(type=DSA) + modulus: + description: + - The RSA key's modulus. + type: int + returned: When C(type=RSA) + exponent: + description: + - The RSA key's public exponent. + type: int + returned: When C(type=RSA) + p: + description: + - The C(p) value for DSA. + - This is the prime modulus upon which arithmetic takes place. + type: int + returned: When C(type=DSA) + q: + description: + - The C(q) value for DSA. + - This is a prime that divides C(p - 1), and at the same time the order of the subgroup of the + multiplicative group of the prime field used. + type: int + returned: When C(type=DSA) + g: + description: + - The C(g) value for DSA. + - This is the element spanning the subgroup of the multiplicative group of the prime field used. + type: int + returned: When C(type=DSA) + curve: + description: + - The curve's name for ECC. + type: str + returned: When C(type=ECC) + exponent_size: + description: + - The maximum number of bits of a private key. This is basically the bit size of the subgroup used. + type: int + returned: When C(type=ECC) + x: + description: + - The C(x) coordinate for the public point on the elliptic curve. + type: int + returned: When C(type=ECC) + y: + description: + - For C(type=ECC), this is the C(y) coordinate for the public point on the elliptic curve. + - For C(type=DSA), this is the publicly known group element whose discrete logarithm w.r.t. C(g) is the private key. + type: int + returned: When C(type=DSA) or C(type=ECC) +''' + +from ansible.errors import AnsibleFilterError +from ansible.module_utils.six import string_types +from ansible.module_utils.common.text.converters import to_bytes, to_native + +from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import ( + OpenSSLObjectError, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.publickey_info import ( + PublicKeyParseError, + get_publickey_info, +) + +from ansible_collections.community.crypto.plugins.plugin_utils.filter_module import FilterModuleMock + + +def openssl_publickey_info_filter(data): + '''Extract information from OpenSSL PEM public key.''' + if not isinstance(data, string_types): + raise AnsibleFilterError('The community.crypto.openssl_publickey_info input must be a text type, not %s' % type(data)) + + module = FilterModuleMock({}) + try: + return get_publickey_info(module, 'cryptography', content=to_bytes(data)) + except PublicKeyParseError as exc: + raise AnsibleFilterError(exc.error_message) + except OpenSSLObjectError as exc: + raise AnsibleFilterError(to_native(exc)) + + +class FilterModule(object): + '''Ansible jinja2 filters''' + + def filters(self): + return { + 'openssl_publickey_info': openssl_publickey_info_filter, + } diff --git a/ansible_collections/community/crypto/plugins/filter/split_pem.py b/ansible_collections/community/crypto/plugins/filter/split_pem.py new file mode 100644 index 00000000..a58ce506 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/filter/split_pem.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2022, 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 = ''' +name: split_pem +short_description: Split PEM file contents into multiple objects +version_added: 2.10.0 +author: + - Felix Fontein (@felixfontein) +description: + - Split PEM file contents into multiple PEM objects. Comments or invalid parts are ignored. +options: + _input: + description: + - The PEM contents to split. + type: string + required: true +''' + +EXAMPLES = ''' +- name: Print all CA certificates + ansible.builtin.debug: + msg: '{{ item }}' + loop: >- + {{ lookup('ansible.builtin.file', '/path/to/ca-bundle.pem') | community.crypto.split_pem }} +''' + +RETURN = ''' +_value: + description: + - A list of PEM file contents. + type: list + elements: string +''' + +from ansible.errors import AnsibleFilterError +from ansible.module_utils.six import string_types +from ansible.module_utils.common.text.converters import to_text + +from ansible_collections.community.crypto.plugins.module_utils.crypto.pem import split_pem_list + + +def split_pem_filter(data): + '''Split PEM file.''' + if not isinstance(data, string_types): + raise AnsibleFilterError('The community.crypto.split_pem input must be a text type, not %s' % type(data)) + + data = to_text(data) + return split_pem_list(data) + + +class FilterModule(object): + '''Ansible jinja2 filters''' + + def filters(self): + return { + 'split_pem': split_pem_filter, + } diff --git a/ansible_collections/community/crypto/plugins/filter/x509_certificate_info.py b/ansible_collections/community/crypto/plugins/filter/x509_certificate_info.py new file mode 100644 index 00000000..21aee98a --- /dev/null +++ b/ansible_collections/community/crypto/plugins/filter/x509_certificate_info.py @@ -0,0 +1,346 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2022, 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 = ''' +name: x509_certificate_info +short_description: Retrieve information from X.509 certificates in PEM format +version_added: 2.10.0 +author: + - Felix Fontein (@felixfontein) +description: + - Provided a X.509 certificate in PEM format, retrieve information. + - This is a filter version of the M(community.crypto.x509_certificate_info) module. +options: + _input: + description: + - The content of the X.509 certificate in PEM format. + type: string + required: true +extends_documentation_fragment: + - community.crypto.name_encoding +seealso: + - module: community.crypto.x509_certificate_info +''' + +EXAMPLES = ''' +- name: Show the Subject Alt Names of the certificate + ansible.builtin.debug: + msg: >- + {{ + ( + lookup('ansible.builtin.file', '/path/to/cert.pem') + | community.crypto.x509_certificate_info + ).subject_alt_name | join(', ') + }} +''' + +RETURN = ''' +_value: + description: + - Information on the certificate. + type: dict + contains: + expired: + description: Whether the certificate is expired (in other words, C(notAfter) is in the past). + returned: success + type: bool + basic_constraints: + description: Entries in the C(basic_constraints) extension, or C(none) if extension is not present. + returned: success + type: list + elements: str + sample: ["CA:TRUE", "pathlen:1"] + basic_constraints_critical: + description: Whether the C(basic_constraints) extension is critical. + returned: success + type: bool + extended_key_usage: + description: Entries in the C(extended_key_usage) extension, or C(none) if extension is not present. + returned: success + type: list + elements: str + sample: [Biometric Info, DVCS, Time Stamping] + extended_key_usage_critical: + description: Whether the C(extended_key_usage) extension is critical. + returned: success + type: bool + extensions_by_oid: + description: Returns a dictionary for every extension OID. + returned: success + type: dict + contains: + critical: + description: Whether the extension is critical. + returned: success + type: bool + value: + description: + - The Base64 encoded value (in DER format) of the extension. + - B(Note) that depending on the C(cryptography) version used, it is + not possible to extract the ASN.1 content of the extension, but only + to provide the re-encoded content of the extension in case it was + parsed by C(cryptography). This should usually result in exactly the + same value, except if the original extension value was malformed. + returned: success + type: str + sample: "MAMCAQU=" + sample: {"1.3.6.1.5.5.7.1.24": { "critical": false, "value": "MAMCAQU="}} + key_usage: + description: Entries in the C(key_usage) extension, or C(none) if extension is not present. + returned: success + type: str + sample: [Key Agreement, Data Encipherment] + key_usage_critical: + description: Whether the C(key_usage) extension is critical. + returned: success + type: bool + subject_alt_name: + description: + - Entries in the C(subject_alt_name) extension, or C(none) if extension is not present. + - See I(name_encoding) for how IDNs are handled. + returned: success + type: list + elements: str + sample: ["DNS:www.ansible.com", "IP:1.2.3.4"] + subject_alt_name_critical: + description: Whether the C(subject_alt_name) extension is critical. + returned: success + type: bool + ocsp_must_staple: + description: C(true) if the OCSP Must Staple extension is present, C(none) otherwise. + returned: success + type: bool + ocsp_must_staple_critical: + description: Whether the C(ocsp_must_staple) extension is critical. + returned: success + type: bool + issuer: + description: + - The certificate's issuer. + - Note that for repeated values, only the last one will be returned. + returned: success + type: dict + sample: {"organizationName": "Ansible", "commonName": "ca.example.com"} + issuer_ordered: + description: The certificate's issuer as an ordered list of tuples. + returned: success + type: list + elements: list + sample: [["organizationName", "Ansible"], ["commonName": "ca.example.com"]] + subject: + description: + - The certificate's subject as a dictionary. + - Note that for repeated values, only the last one will be returned. + returned: success + type: dict + sample: {"commonName": "www.example.com", "emailAddress": "test@example.com"} + subject_ordered: + description: The certificate's subject as an ordered list of tuples. + returned: success + type: list + elements: list + sample: [["commonName", "www.example.com"], ["emailAddress": "test@example.com"]] + not_after: + description: C(notAfter) date as ASN.1 TIME. + returned: success + type: str + sample: '20190413202428Z' + not_before: + description: C(notBefore) date as ASN.1 TIME. + returned: success + type: str + sample: '20190331202428Z' + public_key: + description: Certificate's public key in PEM format. + returned: success + type: str + sample: "-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8A..." + public_key_type: + description: + - The certificate's public key's type. + - One of C(RSA), C(DSA), C(ECC), C(Ed25519), C(X25519), C(Ed448), or C(X448). + - Will start with C(unknown) if the key type cannot be determined. + returned: success + type: str + sample: RSA + public_key_data: + description: + - Public key data. Depends on the public key's type. + returned: success + type: dict + contains: + size: + description: + - Bit size of modulus (RSA) or prime number (DSA). + type: int + returned: When C(public_key_type=RSA) or C(public_key_type=DSA) + modulus: + description: + - The RSA key's modulus. + type: int + returned: When C(public_key_type=RSA) + exponent: + description: + - The RSA key's public exponent. + type: int + returned: When C(public_key_type=RSA) + p: + description: + - The C(p) value for DSA. + - This is the prime modulus upon which arithmetic takes place. + type: int + returned: When C(public_key_type=DSA) + q: + description: + - The C(q) value for DSA. + - This is a prime that divides C(p - 1), and at the same time the order of the subgroup of the + multiplicative group of the prime field used. + type: int + returned: When C(public_key_type=DSA) + g: + description: + - The C(g) value for DSA. + - This is the element spanning the subgroup of the multiplicative group of the prime field used. + type: int + returned: When C(public_key_type=DSA) + curve: + description: + - The curve's name for ECC. + type: str + returned: When C(public_key_type=ECC) + exponent_size: + description: + - The maximum number of bits of a private key. This is basically the bit size of the subgroup used. + type: int + returned: When C(public_key_type=ECC) + x: + description: + - The C(x) coordinate for the public point on the elliptic curve. + type: int + returned: When C(public_key_type=ECC) + y: + description: + - For C(public_key_type=ECC), this is the C(y) coordinate for the public point on the elliptic curve. + - For C(public_key_type=DSA), this is the publicly known group element whose discrete logarithm w.r.t. C(g) is the private key. + type: int + returned: When C(public_key_type=DSA) or C(public_key_type=ECC) + public_key_fingerprints: + description: + - Fingerprints of certificate's public key. + - For every hash algorithm available, the fingerprint is computed. + returned: success + type: dict + sample: "{'sha256': 'd4:b3:aa:6d:c8:04:ce:4e:ba:f6:29:4d:92:a3:94:b0:c2:ff:bd:bf:33:63:11:43:34:0f:51:b0:95:09:2f:63', + 'sha512': 'f7:07:4a:f0:b0:f0:e6:8b:95:5f:f9:e6:61:0a:32:68:f1..." + fingerprints: + description: + - Fingerprints of the DER-encoded form of the whole certificate. + - For every hash algorithm available, the fingerprint is computed. + returned: success + type: dict + sample: "{'sha256': 'd4:b3:aa:6d:c8:04:ce:4e:ba:f6:29:4d:92:a3:94:b0:c2:ff:bd:bf:33:63:11:43:34:0f:51:b0:95:09:2f:63', + 'sha512': 'f7:07:4a:f0:b0:f0:e6:8b:95:5f:f9:e6:61:0a:32:68:f1..." + signature_algorithm: + description: The signature algorithm used to sign the certificate. + returned: success + type: str + sample: sha256WithRSAEncryption + serial_number: + description: The certificate's serial number. + returned: success + type: int + sample: 1234 + version: + description: The certificate version. + returned: success + type: int + sample: 3 + subject_key_identifier: + description: + - The certificate's subject key identifier. + - The identifier is returned in hexadecimal, with C(:) used to separate bytes. + - Is C(none) if the C(SubjectKeyIdentifier) extension is not present. + returned: success + type: str + sample: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff:00:11:22:33' + authority_key_identifier: + description: + - The certificate's authority key identifier. + - The identifier is returned in hexadecimal, with C(:) used to separate bytes. + - Is C(none) if the C(AuthorityKeyIdentifier) extension is not present. + returned: success + type: str + sample: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff:00:11:22:33' + authority_cert_issuer: + description: + - The certificate's authority cert issuer as a list of general names. + - Is C(none) if the C(AuthorityKeyIdentifier) extension is not present. + - See I(name_encoding) for how IDNs are handled. + returned: success + type: list + elements: str + sample: ["DNS:www.ansible.com", "IP:1.2.3.4"] + authority_cert_serial_number: + description: + - The certificate's authority cert serial number. + - Is C(none) if the C(AuthorityKeyIdentifier) extension is not present. + returned: success + type: int + sample: 12345 + ocsp_uri: + description: The OCSP responder URI, if included in the certificate. Will be + C(none) if no OCSP responder URI is included. + returned: success + type: str + issuer_uri: + description: The Issuer URI, if included in the certificate. Will be + C(none) if no issuer URI is included. + returned: success + type: str +''' + +from ansible.errors import AnsibleFilterError +from ansible.module_utils.six import string_types +from ansible.module_utils.common.text.converters import to_bytes, to_native + +from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import ( + OpenSSLObjectError, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.certificate_info import ( + get_certificate_info, +) + +from ansible_collections.community.crypto.plugins.plugin_utils.filter_module import FilterModuleMock + + +def x509_certificate_info_filter(data, name_encoding='ignore'): + '''Extract information from X.509 PEM certificate.''' + if not isinstance(data, string_types): + raise AnsibleFilterError('The community.crypto.x509_certificate_info input must be a text type, not %s' % type(data)) + if not isinstance(name_encoding, string_types): + raise AnsibleFilterError('The name_encoding option must be of a text type, not %s' % type(name_encoding)) + name_encoding = to_native(name_encoding) + if name_encoding not in ('ignore', 'idna', 'unicode'): + raise AnsibleFilterError('The name_encoding option must be one of the values "ignore", "idna", or "unicode", not "%s"' % name_encoding) + + module = FilterModuleMock({'name_encoding': name_encoding}) + try: + return get_certificate_info(module, 'cryptography', content=to_bytes(data)) + except OpenSSLObjectError as exc: + raise AnsibleFilterError(to_native(exc)) + + +class FilterModule(object): + '''Ansible jinja2 filters''' + + def filters(self): + return { + 'x509_certificate_info': x509_certificate_info_filter, + } diff --git a/ansible_collections/community/crypto/plugins/filter/x509_crl_info.py b/ansible_collections/community/crypto/plugins/filter/x509_crl_info.py new file mode 100644 index 00000000..11f61fd8 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/filter/x509_crl_info.py @@ -0,0 +1,196 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2022, 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 = ''' +name: x509_crl_info +short_description: Retrieve information from X.509 CRLs in PEM format +version_added: 2.10.0 +author: + - Felix Fontein (@felixfontein) +description: + - Provided a X.509 crl in PEM format, retrieve information. + - This is a filter version of the M(community.crypto.x509_crl_info) module. +options: + _input: + description: + - The content of the X.509 CRL in PEM format. + type: string + required: true + list_revoked_certificates: + description: + - If set to C(false), the list of revoked certificates is not included in the result. + - This is useful when retrieving information on large CRL files. Enumerating all revoked + certificates can take some time, including serializing the result as JSON, sending it to + the Ansible controller, and decoding it again. + type: bool + default: true + version_added: 1.7.0 +extends_documentation_fragment: + - community.crypto.name_encoding +seealso: + - module: community.crypto.x509_crl_info +''' + +EXAMPLES = ''' +- name: Show the Organization Name of the CRL's subject + ansible.builtin.debug: + msg: >- + {{ + ( + lookup('ansible.builtin.file', '/path/to/cert.pem') + | community.crypto.x509_crl_info + ).issuer.organizationName + }} +''' + +RETURN = ''' +_value: + description: + - Information on the CRL. + type: dict + contains: + format: + description: + - Whether the CRL is in PEM format (C(pem)) or in DER format (C(der)). + returned: success + type: str + sample: pem + issuer: + description: + - The CRL's issuer. + - Note that for repeated values, only the last one will be returned. + - See I(name_encoding) for how IDNs are handled. + returned: success + type: dict + sample: {"organizationName": "Ansible", "commonName": "ca.example.com"} + issuer_ordered: + description: The CRL's issuer as an ordered list of tuples. + returned: success + type: list + elements: list + sample: [["organizationName", "Ansible"], ["commonName": "ca.example.com"]] + last_update: + description: The point in time from which this CRL can be trusted as ASN.1 TIME. + returned: success + type: str + sample: '20190413202428Z' + next_update: + description: The point in time from which a new CRL will be issued and the client has to check for it as ASN.1 TIME. + returned: success + type: str + sample: '20190413202428Z' + digest: + description: The signature algorithm used to sign the CRL. + returned: success + type: str + sample: sha256WithRSAEncryption + revoked_certificates: + description: List of certificates to be revoked. + returned: success if I(list_revoked_certificates=true) + type: list + elements: dict + contains: + serial_number: + description: Serial number of the certificate. + type: int + sample: 1234 + revocation_date: + description: The point in time the certificate was revoked as ASN.1 TIME. + type: str + sample: '20190413202428Z' + issuer: + description: + - The certificate's issuer. + - See I(name_encoding) for how IDNs are handled. + type: list + elements: str + sample: ["DNS:ca.example.org"] + issuer_critical: + description: Whether the certificate issuer extension is critical. + type: bool + sample: false + reason: + description: + - The value for the revocation reason extension. + - One of C(unspecified), C(key_compromise), C(ca_compromise), C(affiliation_changed), C(superseded), + C(cessation_of_operation), C(certificate_hold), C(privilege_withdrawn), C(aa_compromise), and + C(remove_from_crl). + type: str + sample: key_compromise + reason_critical: + description: Whether the revocation reason extension is critical. + type: bool + sample: false + invalidity_date: + description: | + The point in time it was known/suspected that the private key was compromised + or that the certificate otherwise became invalid as ASN.1 TIME. + type: str + sample: '20190413202428Z' + invalidity_date_critical: + description: Whether the invalidity date extension is critical. + type: bool + sample: false +''' + +import base64 +import binascii + +from ansible.errors import AnsibleFilterError +from ansible.module_utils.six import string_types +from ansible.module_utils.common.text.converters import to_bytes, to_native + +from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import ( + OpenSSLObjectError, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.pem import ( + identify_pem_format, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.crl_info import ( + get_crl_info, +) + +from ansible_collections.community.crypto.plugins.plugin_utils.filter_module import FilterModuleMock + + +def x509_crl_info_filter(data, name_encoding='ignore', list_revoked_certificates=True): + '''Extract information from X.509 PEM certificate.''' + if not isinstance(data, string_types): + raise AnsibleFilterError('The community.crypto.x509_crl_info input must be a text type, not %s' % type(data)) + if not isinstance(name_encoding, string_types): + raise AnsibleFilterError('The name_encoding option must be of a text type, not %s' % type(name_encoding)) + if not isinstance(list_revoked_certificates, bool): + raise AnsibleFilterError('The list_revoked_certificates option must be a boolean, not %s' % type(list_revoked_certificates)) + name_encoding = to_native(name_encoding) + if name_encoding not in ('ignore', 'idna', 'unicode'): + raise AnsibleFilterError('The name_encoding option must be one of the values "ignore", "idna", or "unicode", not "%s"' % name_encoding) + + data = to_bytes(data) + if not identify_pem_format(data): + try: + data = base64.b64decode(to_native(data)) + except (binascii.Error, TypeError, ValueError, UnicodeEncodeError) as e: + pass + + module = FilterModuleMock({'name_encoding': name_encoding}) + try: + return get_crl_info(module, content=data, list_revoked_certificates=list_revoked_certificates) + except OpenSSLObjectError as exc: + raise AnsibleFilterError(to_native(exc)) + + +class FilterModule(object): + '''Ansible jinja2 filters''' + + def filters(self): + return { + 'x509_crl_info': x509_crl_info_filter, + } diff --git a/ansible_collections/community/crypto/plugins/module_utils/_version.py b/ansible_collections/community/crypto/plugins/module_utils/_version.py new file mode 100644 index 00000000..f7954074 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/_version.py @@ -0,0 +1,345 @@ +# Vendored copy of distutils/version.py from CPython 3.9.5 +# +# Implements multiple version numbering conventions for the +# Python Module Distribution Utilities. +# +# Copyright (c) 2001-2022 Python Software Foundation. All rights reserved. +# PSF License (see LICENSES/PSF-2.0.txt or https://opensource.org/licenses/Python-2.0) +# SPDX-License-Identifier: PSF-2.0 +# + +"""Provides classes to represent module version numbers (one class for +each style of version numbering). There are currently two such classes +implemented: StrictVersion and LooseVersion. + +Every version number class implements the following interface: + * the 'parse' method takes a string and parses it to some internal + representation; if the string is an invalid version number, + 'parse' raises a ValueError exception + * the class constructor takes an optional string argument which, + if supplied, is passed to 'parse' + * __str__ reconstructs the string that was passed to 'parse' (or + an equivalent string -- ie. one that will generate an equivalent + version number instance) + * __repr__ generates Python code to recreate the version number instance + * _cmp compares the current instance with either another instance + of the same class or a string (which will be parsed to an instance + of the same class, thus must follow the same rules) +""" + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import re + +try: + RE_FLAGS = re.VERBOSE | re.ASCII +except AttributeError: + RE_FLAGS = re.VERBOSE + + +class Version: + """Abstract base class for version numbering classes. Just provides + constructor (__init__) and reproducer (__repr__), because those + seem to be the same for all version numbering classes; and route + rich comparisons to _cmp. + """ + + def __init__(self, vstring=None): + if vstring: + self.parse(vstring) + + def __repr__(self): + return "%s ('%s')" % (self.__class__.__name__, str(self)) + + def __eq__(self, other): + c = self._cmp(other) + if c is NotImplemented: + return c + return c == 0 + + def __lt__(self, other): + c = self._cmp(other) + if c is NotImplemented: + return c + return c < 0 + + def __le__(self, other): + c = self._cmp(other) + if c is NotImplemented: + return c + return c <= 0 + + def __gt__(self, other): + c = self._cmp(other) + if c is NotImplemented: + return c + return c > 0 + + def __ge__(self, other): + c = self._cmp(other) + if c is NotImplemented: + return c + return c >= 0 + + +# Interface for version-number classes -- must be implemented +# by the following classes (the concrete ones -- Version should +# be treated as an abstract class). +# __init__ (string) - create and take same action as 'parse' +# (string parameter is optional) +# parse (string) - convert a string representation to whatever +# internal representation is appropriate for +# this style of version numbering +# __str__ (self) - convert back to a string; should be very similar +# (if not identical to) the string supplied to parse +# __repr__ (self) - generate Python code to recreate +# the instance +# _cmp (self, other) - compare two version numbers ('other' may +# be an unparsed version string, or another +# instance of your version class) + + +class StrictVersion(Version): + """Version numbering for anal retentives and software idealists. + Implements the standard interface for version number classes as + described above. A version number consists of two or three + dot-separated numeric components, with an optional "pre-release" tag + on the end. The pre-release tag consists of the letter 'a' or 'b' + followed by a number. If the numeric components of two version + numbers are equal, then one with a pre-release tag will always + be deemed earlier (lesser) than one without. + + The following are valid version numbers (shown in the order that + would be obtained by sorting according to the supplied cmp function): + + 0.4 0.4.0 (these two are equivalent) + 0.4.1 + 0.5a1 + 0.5b3 + 0.5 + 0.9.6 + 1.0 + 1.0.4a3 + 1.0.4b1 + 1.0.4 + + The following are examples of invalid version numbers: + + 1 + 2.7.2.2 + 1.3.a4 + 1.3pl1 + 1.3c4 + + The rationale for this version numbering system will be explained + in the distutils documentation. + """ + + version_re = re.compile(r'^(\d+) \. (\d+) (\. (\d+))? ([ab](\d+))?$', + RE_FLAGS) + + def parse(self, vstring): + match = self.version_re.match(vstring) + if not match: + raise ValueError("invalid version number '%s'" % vstring) + + (major, minor, patch, prerelease, prerelease_num) = \ + match.group(1, 2, 4, 5, 6) + + if patch: + self.version = tuple(map(int, [major, minor, patch])) + else: + self.version = tuple(map(int, [major, minor])) + (0,) + + if prerelease: + self.prerelease = (prerelease[0], int(prerelease_num)) + else: + self.prerelease = None + + def __str__(self): + if self.version[2] == 0: + vstring = '.'.join(map(str, self.version[0:2])) + else: + vstring = '.'.join(map(str, self.version)) + + if self.prerelease: + vstring = vstring + self.prerelease[0] + str(self.prerelease[1]) + + return vstring + + def _cmp(self, other): + if isinstance(other, str): + other = StrictVersion(other) + elif not isinstance(other, StrictVersion): + return NotImplemented + + if self.version != other.version: + # numeric versions don't match + # prerelease stuff doesn't matter + if self.version < other.version: + return -1 + else: + return 1 + + # have to compare prerelease + # case 1: neither has prerelease; they're equal + # case 2: self has prerelease, other doesn't; other is greater + # case 3: self doesn't have prerelease, other does: self is greater + # case 4: both have prerelease: must compare them! + + if (not self.prerelease and not other.prerelease): + return 0 + elif (self.prerelease and not other.prerelease): + return -1 + elif (not self.prerelease and other.prerelease): + return 1 + elif (self.prerelease and other.prerelease): + if self.prerelease == other.prerelease: + return 0 + elif self.prerelease < other.prerelease: + return -1 + else: + return 1 + else: + raise AssertionError("never get here") + +# end class StrictVersion + +# The rules according to Greg Stein: +# 1) a version number has 1 or more numbers separated by a period or by +# sequences of letters. If only periods, then these are compared +# left-to-right to determine an ordering. +# 2) sequences of letters are part of the tuple for comparison and are +# compared lexicographically +# 3) recognize the numeric components may have leading zeroes +# +# The LooseVersion class below implements these rules: a version number +# string is split up into a tuple of integer and string components, and +# comparison is a simple tuple comparison. This means that version +# numbers behave in a predictable and obvious way, but a way that might +# not necessarily be how people *want* version numbers to behave. There +# wouldn't be a problem if people could stick to purely numeric version +# numbers: just split on period and compare the numbers as tuples. +# However, people insist on putting letters into their version numbers; +# the most common purpose seems to be: +# - indicating a "pre-release" version +# ('alpha', 'beta', 'a', 'b', 'pre', 'p') +# - indicating a post-release patch ('p', 'pl', 'patch') +# but of course this can't cover all version number schemes, and there's +# no way to know what a programmer means without asking him. +# +# The problem is what to do with letters (and other non-numeric +# characters) in a version number. The current implementation does the +# obvious and predictable thing: keep them as strings and compare +# lexically within a tuple comparison. This has the desired effect if +# an appended letter sequence implies something "post-release": +# eg. "0.99" < "0.99pl14" < "1.0", and "5.001" < "5.001m" < "5.002". +# +# However, if letters in a version number imply a pre-release version, +# the "obvious" thing isn't correct. Eg. you would expect that +# "1.5.1" < "1.5.2a2" < "1.5.2", but under the tuple/lexical comparison +# implemented here, this just isn't so. +# +# Two possible solutions come to mind. The first is to tie the +# comparison algorithm to a particular set of semantic rules, as has +# been done in the StrictVersion class above. This works great as long +# as everyone can go along with bondage and discipline. Hopefully a +# (large) subset of Python module programmers will agree that the +# particular flavour of bondage and discipline provided by StrictVersion +# provides enough benefit to be worth using, and will submit their +# version numbering scheme to its domination. The free-thinking +# anarchists in the lot will never give in, though, and something needs +# to be done to accommodate them. +# +# Perhaps a "moderately strict" version class could be implemented that +# lets almost anything slide (syntactically), and makes some heuristic +# assumptions about non-digits in version number strings. This could +# sink into special-case-hell, though; if I was as talented and +# idiosyncratic as Larry Wall, I'd go ahead and implement a class that +# somehow knows that "1.2.1" < "1.2.2a2" < "1.2.2" < "1.2.2pl3", and is +# just as happy dealing with things like "2g6" and "1.13++". I don't +# think I'm smart enough to do it right though. +# +# In any case, I've coded the test suite for this module (see +# ../test/test_version.py) specifically to fail on things like comparing +# "1.2a2" and "1.2". That's not because the *code* is doing anything +# wrong, it's because the simple, obvious design doesn't match my +# complicated, hairy expectations for real-world version numbers. It +# would be a snap to fix the test suite to say, "Yep, LooseVersion does +# the Right Thing" (ie. the code matches the conception). But I'd rather +# have a conception that matches common notions about version numbers. + + +class LooseVersion(Version): + """Version numbering for anarchists and software realists. + Implements the standard interface for version number classes as + described above. A version number consists of a series of numbers, + separated by either periods or strings of letters. When comparing + version numbers, the numeric components will be compared + numerically, and the alphabetic components lexically. The following + are all valid version numbers, in no particular order: + + 1.5.1 + 1.5.2b2 + 161 + 3.10a + 8.02 + 3.4j + 1996.07.12 + 3.2.pl0 + 3.1.1.6 + 2g6 + 11g + 0.960923 + 2.2beta29 + 1.13++ + 5.5.kw + 2.0b1pl0 + + In fact, there is no such thing as an invalid version number under + this scheme; the rules for comparison are simple and predictable, + but may not always give the results you want (for some definition + of "want"). + """ + + component_re = re.compile(r'(\d+ | [a-z]+ | \.)', re.VERBOSE) + + def __init__(self, vstring=None): + if vstring: + self.parse(vstring) + + def parse(self, vstring): + # I've given up on thinking I can reconstruct the version string + # from the parsed tuple -- so I just store the string here for + # use by __str__ + self.vstring = vstring + components = [x for x in self.component_re.split(vstring) if x and x != '.'] + for i, obj in enumerate(components): + try: + components[i] = int(obj) + except ValueError: + pass + + self.version = components + + def __str__(self): + return self.vstring + + def __repr__(self): + return "LooseVersion ('%s')" % str(self) + + def _cmp(self, other): + if isinstance(other, str): + other = LooseVersion(other) + elif not isinstance(other, LooseVersion): + return NotImplemented + + if self.version == other.version: + return 0 + if self.version < other.version: + return -1 + if self.version > other.version: + return 1 + +# end class LooseVersion diff --git a/ansible_collections/community/crypto/plugins/module_utils/acme/account.py b/ansible_collections/community/crypto/plugins/module_utils/acme/account.py new file mode 100644 index 00000000..97e16498 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/acme/account.py @@ -0,0 +1,252 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net> +# Copyright (c) 2021 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_collections.community.crypto.plugins.module_utils.acme.errors import ( + ACMEProtocolException, + ModuleFailException, +) + + +class ACMEAccount(object): + ''' + ACME account object. Allows to create new accounts, check for existence of accounts, + retrieve account data. + ''' + + def __init__(self, client): + # Set to true to enable logging of all signed requests + self._debug = False + + self.client = client + + def _new_reg(self, contact=None, agreement=None, terms_agreed=False, allow_creation=True, + external_account_binding=None): + ''' + Registers a new ACME account. Returns a pair ``(created, data)``. + Here, ``created`` is ``True`` if the account was created and + ``False`` if it already existed (e.g. it was not newly created), + or does not exist. In case the account was created or exists, + ``data`` contains the account data; otherwise, it is ``None``. + + If specified, ``external_account_binding`` should be a dictionary + with keys ``kid``, ``alg`` and ``key`` + (https://tools.ietf.org/html/rfc8555#section-7.3.4). + + https://tools.ietf.org/html/rfc8555#section-7.3 + ''' + contact = contact or [] + + if self.client.version == 1: + new_reg = { + 'resource': 'new-reg', + 'contact': contact + } + if agreement: + new_reg['agreement'] = agreement + else: + new_reg['agreement'] = self.client.directory['meta']['terms-of-service'] + if external_account_binding is not None: + raise ModuleFailException('External account binding is not supported for ACME v1') + url = self.client.directory['new-reg'] + else: + if (external_account_binding is not None or self.client.directory['meta'].get('externalAccountRequired')) and allow_creation: + # Some ACME servers such as ZeroSSL do not like it when you try to register an existing account + # and provide external_account_binding credentials. Thus we first send a request with allow_creation=False + # to see whether the account already exists. + + # Note that we pass contact here: ZeroSSL does not accept regisration calls without contacts, even + # if onlyReturnExisting is set to true. + created, data = self._new_reg(contact=contact, allow_creation=False) + if data: + # An account already exists! Return data + return created, data + # An account does not yet exist. Try to create one next. + + new_reg = { + 'contact': contact + } + if not allow_creation: + # https://tools.ietf.org/html/rfc8555#section-7.3.1 + new_reg['onlyReturnExisting'] = True + if terms_agreed: + new_reg['termsOfServiceAgreed'] = True + url = self.client.directory['newAccount'] + if external_account_binding is not None: + new_reg['externalAccountBinding'] = self.client.sign_request( + { + 'alg': external_account_binding['alg'], + 'kid': external_account_binding['kid'], + 'url': url, + }, + self.client.account_jwk, + self.client.backend.create_mac_key(external_account_binding['alg'], external_account_binding['key']) + ) + elif self.client.directory['meta'].get('externalAccountRequired') and allow_creation: + raise ModuleFailException( + 'To create an account, an external account binding must be specified. ' + 'Use the acme_account module with the external_account_binding option.' + ) + + result, info = self.client.send_signed_request(url, new_reg, fail_on_error=False) + + if info['status'] in ([200, 201] if self.client.version == 1 else [201]): + # Account did not exist + if 'location' in info: + self.client.set_account_uri(info['location']) + return True, result + elif info['status'] == (409 if self.client.version == 1 else 200): + # Account did exist + if result.get('status') == 'deactivated': + # A bug in Pebble (https://github.com/letsencrypt/pebble/issues/179) and + # Boulder (https://github.com/letsencrypt/boulder/issues/3971): this should + # not return a valid account object according to + # https://tools.ietf.org/html/rfc8555#section-7.3.6: + # "Once an account is deactivated, the server MUST NOT accept further + # requests authorized by that account's key." + if not allow_creation: + return False, None + else: + raise ModuleFailException("Account is deactivated") + if 'location' in info: + self.client.set_account_uri(info['location']) + return False, result + elif info['status'] == 400 and result['type'] == 'urn:ietf:params:acme:error:accountDoesNotExist' and not allow_creation: + # Account does not exist (and we did not try to create it) + return False, None + elif info['status'] == 403 and result['type'] == 'urn:ietf:params:acme:error:unauthorized' and 'deactivated' in (result.get('detail') or ''): + # Account has been deactivated; currently works for Pebble; has not been + # implemented for Boulder (https://github.com/letsencrypt/boulder/issues/3971), + # might need adjustment in error detection. + if not allow_creation: + return False, None + else: + raise ModuleFailException("Account is deactivated") + else: + raise ACMEProtocolException( + self.client.module, msg='Registering ACME account failed', info=info, content_json=result) + + def get_account_data(self): + ''' + Retrieve account information. Can only be called when the account + URI is already known (such as after calling setup_account). + Return None if the account was deactivated, or a dict otherwise. + ''' + if self.client.account_uri is None: + raise ModuleFailException("Account URI unknown") + if self.client.version == 1: + data = {} + data['resource'] = 'reg' + result, info = self.client.send_signed_request(self.client.account_uri, data, fail_on_error=False) + else: + # try POST-as-GET first (draft-15 or newer) + data = None + result, info = self.client.send_signed_request(self.client.account_uri, data, fail_on_error=False) + # check whether that failed with a malformed request error + if info['status'] >= 400 and result.get('type') == 'urn:ietf:params:acme:error:malformed': + # retry as a regular POST (with no changed data) for pre-draft-15 ACME servers + data = {} + result, info = self.client.send_signed_request(self.client.account_uri, data, fail_on_error=False) + if info['status'] in (400, 403) and result.get('type') == 'urn:ietf:params:acme:error:unauthorized': + # Returned when account is deactivated + return None + if info['status'] in (400, 404) and result.get('type') == 'urn:ietf:params:acme:error:accountDoesNotExist': + # Returned when account does not exist + return None + if info['status'] < 200 or info['status'] >= 300: + raise ACMEProtocolException( + self.client.module, msg='Error retrieving account data', info=info, content_json=result) + return result + + def setup_account(self, contact=None, agreement=None, terms_agreed=False, + allow_creation=True, remove_account_uri_if_not_exists=False, + external_account_binding=None): + ''' + Detect or create an account on the ACME server. For ACME v1, + as the only way (without knowing an account URI) to test if an + account exists is to try and create one with the provided account + key, this method will always result in an account being present + (except on error situations). For ACME v2, a new account will + only be created if ``allow_creation`` is set to True. + + For ACME v2, ``check_mode`` is fully respected. For ACME v1, the + account might be created if it does not yet exist. + + Return a pair ``(created, account_data)``. Here, ``created`` will + be ``True`` in case the account was created or would be created + (check mode). ``account_data`` will be the current account data, + or ``None`` if the account does not exist. + + The account URI will be stored in ``client.account_uri``; if it is ``None``, + the account does not exist. + + If specified, ``external_account_binding`` should be a dictionary + with keys ``kid``, ``alg`` and ``key`` + (https://tools.ietf.org/html/rfc8555#section-7.3.4). + + https://tools.ietf.org/html/rfc8555#section-7.3 + ''' + + if self.client.account_uri is not None: + created = False + # Verify that the account key belongs to the URI. + # (If update_contact is True, this will be done below.) + account_data = self.get_account_data() + if account_data is None: + if remove_account_uri_if_not_exists and not allow_creation: + self.client.account_uri = None + else: + raise ModuleFailException("Account is deactivated or does not exist!") + else: + created, account_data = self._new_reg( + contact, + agreement=agreement, + terms_agreed=terms_agreed, + allow_creation=allow_creation and not self.client.module.check_mode, + external_account_binding=external_account_binding, + ) + if self.client.module.check_mode and self.client.account_uri is None and allow_creation: + created = True + account_data = { + 'contact': contact or [] + } + return created, account_data + + def update_account(self, account_data, contact=None): + ''' + Update an account on the ACME server. Check mode is fully respected. + + The current account data must be provided as ``account_data``. + + Return a pair ``(updated, account_data)``, where ``updated`` is + ``True`` in case something changed (contact info updated) or + would be changed (check mode), and ``account_data`` the updated + account data. + + https://tools.ietf.org/html/rfc8555#section-7.3.2 + ''' + # Create request + update_request = {} + if contact is not None and account_data.get('contact', []) != contact: + update_request['contact'] = list(contact) + + # No change? + if not update_request: + return False, dict(account_data) + + # Apply change + if self.client.module.check_mode: + account_data = dict(account_data) + account_data.update(update_request) + else: + if self.client.version == 1: + update_request['resource'] = 'reg' + account_data, dummy = self.client.send_signed_request(self.client.account_uri, update_request) + return True, account_data diff --git a/ansible_collections/community/crypto/plugins/module_utils/acme/acme.py b/ansible_collections/community/crypto/plugins/module_utils/acme/acme.py new file mode 100644 index 00000000..c054a52f --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/acme/acme.py @@ -0,0 +1,452 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net> +# Copyright (c) 2021 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 copy +import datetime +import json +import locale +import time +import traceback + +from ansible.module_utils.basic import missing_required_lib +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.acme.backend_openssl_cli import ( + OpenSSLCLIBackend, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.backend_cryptography import ( + CryptographyBackend, + CRYPTOGRAPHY_ERROR, + CRYPTOGRAPHY_MINIMAL_VERSION, + CRYPTOGRAPHY_VERSION, + HAS_CURRENT_CRYPTOGRAPHY, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ( + ACMEProtocolException, + NetworkException, + ModuleFailException, + KeyParsingError, + format_http_status, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.utils import ( + nopad_b64, +) + +try: + import ipaddress # noqa: F401, pylint: disable=unused-import +except ImportError: + HAS_IPADDRESS = False + IPADDRESS_IMPORT_ERROR = traceback.format_exc() +else: + HAS_IPADDRESS = True + IPADDRESS_IMPORT_ERROR = None + + +RETRY_STATUS_CODES = (408, 429, 503) + + +def _decode_retry(module, response, info, retry_count): + if info['status'] not in RETRY_STATUS_CODES: + return False + + if retry_count >= 5: + raise ACMEProtocolException(module, msg='Giving up after 5 retries', info=info, response=response) + + # 429 and 503 should have a Retry-After header (https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) + try: + retry_after = min(max(1, int(info.get('retry-after'))), 60) + except (TypeError, ValueError) as dummy: + retry_after = 10 + module.log('Retrieved a %s HTTP status on %s, retrying in %s seconds' % (format_http_status(info['status']), info['url'], retry_after)) + + time.sleep(retry_after) + return True + + +def _assert_fetch_url_success(module, response, info, allow_redirect=False, allow_client_error=True, allow_server_error=True): + if info['status'] < 0: + raise NetworkException(msg="Failure downloading %s, %s" % (info['url'], info['msg'])) + + if (300 <= info['status'] < 400 and not allow_redirect) or \ + (400 <= info['status'] < 500 and not allow_client_error) or \ + (info['status'] >= 500 and not allow_server_error): + raise ACMEProtocolException(module, info=info, response=response) + + +def _is_failed(info, expected_status_codes=None): + if info['status'] < 200 or info['status'] >= 400: + return True + if expected_status_codes is not None and info['status'] not in expected_status_codes: + return True + return False + + +class ACMEDirectory(object): + ''' + The ACME server directory. Gives access to the available resources, + and allows to obtain a Replay-Nonce. The acme_directory URL + needs to support unauthenticated GET requests; ACME endpoints + requiring authentication are not supported. + https://tools.ietf.org/html/rfc8555#section-7.1.1 + ''' + + def __init__(self, module, account): + self.module = module + self.directory_root = module.params['acme_directory'] + self.version = module.params['acme_version'] + + self.directory, dummy = account.get_request(self.directory_root, get_only=True) + + self.request_timeout = module.params['request_timeout'] + + # Check whether self.version matches what we expect + if self.version == 1: + for key in ('new-reg', 'new-authz', 'new-cert'): + if key not in self.directory: + raise ModuleFailException("ACME directory does not seem to follow protocol ACME v1") + if self.version == 2: + for key in ('newNonce', 'newAccount', 'newOrder'): + if key not in self.directory: + raise ModuleFailException("ACME directory does not seem to follow protocol ACME v2") + # Make sure that 'meta' is always available + if 'meta' not in self.directory: + self.directory['meta'] = {} + + def __getitem__(self, key): + return self.directory[key] + + def get_nonce(self, resource=None): + url = self.directory_root if self.version == 1 else self.directory['newNonce'] + if resource is not None: + url = resource + retry_count = 0 + while True: + response, info = fetch_url(self.module, url, method='HEAD', timeout=self.request_timeout) + if _decode_retry(self.module, response, info, retry_count): + retry_count += 1 + continue + if info['status'] not in (200, 204): + raise NetworkException("Failed to get replay-nonce, got status {0}".format(format_http_status(info['status']))) + if 'replay-nonce' in info: + return info['replay-nonce'] + self.module.log( + 'HEAD to {0} did return status {1}, but no replay-nonce header!'.format(url, format_http_status(info['status']))) + if retry_count >= 5: + raise ACMEProtocolException( + self.module, msg='Was not able to obtain nonce, giving up after 5 retries', info=info, response=response) + retry_count += 1 + + +class ACMEClient(object): + ''' + ACME client object. Handles the authorized communication with the + ACME server. + ''' + + def __init__(self, module, backend): + # Set to true to enable logging of all signed requests + self._debug = False + + self.module = module + 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'] + + # Grab account URI from module parameters. + # Make sure empty string is treated as None. + self.account_uri = module.params.get('account_uri') or None + + self.request_timeout = module.params['request_timeout'] + + self.account_key_data = None + self.account_jwk = None + self.account_jws_header = None + if self.account_key_file is not None or self.account_key_content is not None: + try: + self.account_key_data = self.parse_key( + key_file=self.account_key_file, + key_content=self.account_key_content, + passphrase=self.account_key_passphrase) + except KeyParsingError as e: + raise ModuleFailException("Error while parsing account key: {msg}".format(msg=e.msg)) + self.account_jwk = self.account_key_data['jwk'] + self.account_jws_header = { + "alg": self.account_key_data['alg'], + "jwk": self.account_jwk, + } + if self.account_uri: + # Make sure self.account_jws_header is updated + self.set_account_uri(self.account_uri) + + self.directory = ACMEDirectory(module, self) + + def set_account_uri(self, uri): + ''' + Set account URI. For ACME v2, it needs to be used to sending signed + requests. + ''' + self.account_uri = uri + if self.version != 1: + self.account_jws_header.pop('jwk') + self.account_jws_header['kid'] = self.account_uri + + 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. + In case of an error, raises KeyParsingError. + ''' + if key_file is None and key_content is None: + raise AssertionError('One of key_file and key_content must be specified!') + return self.backend.parse_key(key_file, key_content, passphrase=passphrase) + + def sign_request(self, protected, payload, key_data, encode_payload=True): + ''' + Signs an ACME request. + ''' + try: + if payload is None: + # POST-as-GET + payload64 = '' + else: + # POST + if encode_payload: + payload = self.module.jsonify(payload).encode('utf8') + payload64 = nopad_b64(to_bytes(payload)) + protected64 = nopad_b64(self.module.jsonify(protected).encode('utf8')) + except Exception as e: + raise ModuleFailException("Failed to encode payload / headers as JSON: {0}".format(e)) + + return self.backend.sign(payload64, protected64, key_data) + + def _log(self, msg, data=None): + ''' + Write arguments to acme.log when logging is enabled. + ''' + if self._debug: + with open('acme.log', 'ab') as f: + f.write('[{0}] {1}\n'.format(datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S.%s'), msg).encode('utf-8')) + if data is not None: + f.write('{0}\n\n'.format(json.dumps(data, indent=2, sort_keys=True)).encode('utf-8')) + + def send_signed_request(self, url, payload, key_data=None, jws_header=None, parse_json_result=True, + encode_payload=True, fail_on_error=True, error_msg=None, expected_status_codes=None): + ''' + Sends a JWS signed HTTP POST request to the ACME server and returns + the response as dictionary (if parse_json_result is True) or in raw form + (if parse_json_result is False). + https://tools.ietf.org/html/rfc8555#section-6.2 + + If payload is None, a POST-as-GET is performed. + (https://tools.ietf.org/html/rfc8555#section-6.3) + ''' + key_data = key_data or self.account_key_data + jws_header = jws_header or self.account_jws_header + failed_tries = 0 + while True: + protected = copy.deepcopy(jws_header) + protected["nonce"] = self.directory.get_nonce() + if self.version != 1: + protected["url"] = url + + self._log('URL', url) + self._log('protected', protected) + self._log('payload', payload) + data = self.sign_request(protected, payload, key_data, encode_payload=encode_payload) + if self.version == 1: + data["header"] = jws_header.copy() + for k, v in protected.items(): + dummy = data["header"].pop(k, None) + self._log('signed request', data) + data = self.module.jsonify(data) + + headers = { + 'Content-Type': 'application/jose+json', + } + resp, info = fetch_url(self.module, url, data=data, headers=headers, method='POST', timeout=self.request_timeout) + if _decode_retry(self.module, resp, info, failed_tries): + failed_tries += 1 + continue + _assert_fetch_url_success(self.module, resp, info) + result = {} + + try: + # In Python 2, reading from a closed response yields a TypeError. + # In Python 3, read() simply returns '' + if PY3 and resp.closed: + raise TypeError + content = resp.read() + except (AttributeError, TypeError): + content = info.pop('body', None) + + if content or not parse_json_result: + if (parse_json_result and info['content-type'].startswith('application/json')) or 400 <= info['status'] < 600: + try: + decoded_result = self.module.from_json(content.decode('utf8')) + self._log('parsed result', decoded_result) + # In case of badNonce error, try again (up to 5 times) + # (https://tools.ietf.org/html/rfc8555#section-6.7) + if all(( + 400 <= info['status'] < 600, + decoded_result.get('type') == 'urn:ietf:params:acme:error:badNonce', + failed_tries <= 5, + )): + failed_tries += 1 + continue + if parse_json_result: + result = decoded_result + else: + result = content + except ValueError: + raise NetworkException("Failed to parse the ACME response: {0} {1}".format(url, content)) + else: + result = content + + if fail_on_error and _is_failed(info, expected_status_codes=expected_status_codes): + raise ACMEProtocolException( + self.module, msg=error_msg, info=info, content=content, content_json=result if parse_json_result else None) + return result, info + + def get_request(self, uri, parse_json_result=True, headers=None, get_only=False, + fail_on_error=True, error_msg=None, expected_status_codes=None): + ''' + Perform a GET-like request. Will try POST-as-GET for ACMEv2, with fallback + to GET if server replies with a status code of 405. + ''' + if not get_only and self.version != 1: + # Try POST-as-GET + content, info = self.send_signed_request(uri, None, parse_json_result=False, fail_on_error=False) + if info['status'] == 405: + # Instead, do unauthenticated GET + get_only = True + else: + # Do unauthenticated GET + get_only = True + + if get_only: + # Perform unauthenticated GET + retry_count = 0 + while True: + resp, info = fetch_url(self.module, uri, method='GET', headers=headers, timeout=self.request_timeout) + if not _decode_retry(self.module, resp, info, retry_count): + break + retry_count += 1 + + _assert_fetch_url_success(self.module, resp, info) + + try: + # In Python 2, reading from a closed response yields a TypeError. + # In Python 3, read() simply returns '' + if PY3 and resp.closed: + raise TypeError + content = resp.read() + except (AttributeError, TypeError): + content = info.pop('body', None) + + # Process result + parsed_json_result = False + if parse_json_result: + result = {} + if content: + if info['content-type'].startswith('application/json'): + try: + result = self.module.from_json(content.decode('utf8')) + parsed_json_result = True + except ValueError: + raise NetworkException("Failed to parse the ACME response: {0} {1}".format(uri, content)) + else: + result = content + else: + result = content + + if fail_on_error and _is_failed(info, expected_status_codes=expected_status_codes): + raise ACMEProtocolException( + self.module, msg=error_msg, info=info, content=content, content_json=result if parsed_json_result else None) + return result, info + + +def get_default_argspec(): + ''' + Provides default argument spec for the options documented in the acme doc fragment. + ''' + 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), + ) + + +def create_backend(module, needs_acme_v2): + if not HAS_IPADDRESS: + module.fail_json(msg=missing_required_lib('ipaddress'), exception=IPADDRESS_IMPORT_ERROR) + + backend = module.params['select_crypto_backend'] + + # Backend autodetect + if backend == 'auto': + backend = 'cryptography' if HAS_CURRENT_CRYPTOGRAPHY else 'openssl' + + # Create backend object + if backend == 'cryptography': + if CRYPTOGRAPHY_ERROR is not None: + # Either we couldn't import cryptography at all, or there was an unexpected error + if CRYPTOGRAPHY_VERSION is None: + msg = missing_required_lib('cryptography') + else: + msg = 'Unexpected error while preparing cryptography: {0}'.format(CRYPTOGRAPHY_ERROR.splitlines()[-1]) + module.fail_json(msg=msg, exception=CRYPTOGRAPHY_ERROR) + if not HAS_CURRENT_CRYPTOGRAPHY: + # We succeeded importing cryptography, but its version is too old. + module.fail_json( + msg='Found cryptography, but only version {0}. {1}'.format( + CRYPTOGRAPHY_VERSION, + missing_required_lib('cryptography >= {0}'.format(CRYPTOGRAPHY_MINIMAL_VERSION)))) + module.debug('Using cryptography backend (library version {0})'.format(CRYPTOGRAPHY_VERSION)) + module_backend = CryptographyBackend(module) + elif backend == 'openssl': + module.debug('Using OpenSSL binary backend') + module_backend = OpenSSLCLIBackend(module) + else: + module.fail_json(msg='Unknown crypto backend "{0}"!'.format(backend)) + + # Check common module parameters + if not module.params['validate_certs']: + module.warn( + 'Disabling certificate validation for communications with ACME endpoint. ' + 'This should only be done for testing against a local ACME server for ' + 'development purposes, but *never* for production purposes.' + ) + + if needs_acme_v2 and module.params['acme_version'] < 2: + module.fail_json(msg='The {0} module requires the ACME v2 protocol!'.format(module._name)) + + if module.params['acme_version'] == 1: + module.deprecate("The value 1 for 'acme_version' is deprecated. Please switch to ACME v2", + version='3.0.0', collection_name='community.crypto') + + # AnsibleModule() changes the locale, so change it back to C because we rely + # on datetime.datetime.strptime() when parsing certificate dates. + locale.setlocale(locale.LC_ALL, 'C') + + return module_backend 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 new file mode 100644 index 00000000..207f743f --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/acme/backend_cryptography.py @@ -0,0 +1,393 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net> +# Copyright (c) 2021 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 base64 +import binascii +import datetime +import os +import sys +import traceback + +from ansible.module_utils.common.text.converters import to_bytes, to_native, to_text + +from ansible_collections.community.crypto.plugins.module_utils.version import LooseVersion + +from ansible_collections.community.crypto.plugins.module_utils.acme.backends import ( + CryptoBackend, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.certificates import ( + ChainMatcher, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ( + BackendException, + KeyParsingError, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.io import read_file + +from ansible_collections.community.crypto.plugins.module_utils.acme.utils import nopad_b64 + +from ansible_collections.community.crypto.plugins.module_utils.crypto.support import ( + parse_name_field, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_support import ( + cryptography_name_to_oid, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.pem import ( + extract_first_pem, +) + +CRYPTOGRAPHY_MINIMAL_VERSION = '1.5' + +CRYPTOGRAPHY_ERROR = None +try: + import cryptography + import cryptography.hazmat.backends + import cryptography.hazmat.primitives.hashes + import cryptography.hazmat.primitives.hmac + import cryptography.hazmat.primitives.asymmetric.ec + import cryptography.hazmat.primitives.asymmetric.padding + import cryptography.hazmat.primitives.asymmetric.rsa + import cryptography.hazmat.primitives.asymmetric.utils + import cryptography.hazmat.primitives.serialization + import cryptography.x509 + import cryptography.x509.oid +except ImportError as dummy: + HAS_CURRENT_CRYPTOGRAPHY = False + CRYPTOGRAPHY_VERSION = None + CRYPTOGRAPHY_ERROR = traceback.format_exc() +else: + CRYPTOGRAPHY_VERSION = cryptography.__version__ + HAS_CURRENT_CRYPTOGRAPHY = (LooseVersion(CRYPTOGRAPHY_VERSION) >= LooseVersion(CRYPTOGRAPHY_MINIMAL_VERSION)) + try: + if HAS_CURRENT_CRYPTOGRAPHY: + _cryptography_backend = cryptography.hazmat.backends.default_backend() + except Exception as dummy: + CRYPTOGRAPHY_ERROR = traceback.format_exc() + + +if sys.version_info[0] >= 3: + # Python 3 (and newer) + def _count_bytes(n): + return (n.bit_length() + 7) // 8 if n > 0 else 0 + + def _convert_int_to_bytes(count, no): + return no.to_bytes(count, byteorder='big') + + def _pad_hex(n, digits): + res = hex(n)[2:] + if len(res) < digits: + res = '0' * (digits - len(res)) + res + return res +else: + # Python 2 + def _count_bytes(n): + if n <= 0: + return 0 + h = '%x' % n + return (len(h) + 1) // 2 + + def _convert_int_to_bytes(count, n): + 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 _pad_hex(n, digits): + h = '%x' % n + if len(h) < digits: + h = '0' * (digits - len(h)) + h + return h + + +class CryptographyChainMatcher(ChainMatcher): + @staticmethod + def _parse_key_identifier(key_identifier, name, criterium_idx, module): + if key_identifier: + try: + return binascii.unhexlify(key_identifier.replace(':', '')) + except Exception: + if criterium_idx is None: + module.warn('Criterium has invalid {0} value. Ignoring criterium.'.format(name)) + else: + module.warn('Criterium {0} in select_chain has invalid {1} value. ' + 'Ignoring criterium.'.format(criterium_idx, name)) + return None + + def __init__(self, criterium, module): + self.criterium = criterium + self.test_certificates = criterium.test_certificates + self.subject = [] + self.issuer = [] + if criterium.subject: + self.subject = [ + (cryptography_name_to_oid(k), to_native(v)) for k, v in parse_name_field(criterium.subject, 'subject') + ] + if criterium.issuer: + self.issuer = [ + (cryptography_name_to_oid(k), to_native(v)) for k, v in parse_name_field(criterium.issuer, 'issuer') + ] + self.subject_key_identifier = CryptographyChainMatcher._parse_key_identifier( + criterium.subject_key_identifier, 'subject_key_identifier', criterium.index, module) + self.authority_key_identifier = CryptographyChainMatcher._parse_key_identifier( + criterium.authority_key_identifier, 'authority_key_identifier', criterium.index, module) + + def _match_subject(self, x509_subject, match_subject): + for oid, value in match_subject: + found = False + for attribute in x509_subject: + if attribute.oid == oid and value == to_native(attribute.value): + found = True + break + if not found: + return False + return True + + def match(self, certificate): + ''' + Check whether an alternate chain matches the specified criterium. + ''' + chain = certificate.chain + if self.test_certificates == 'last': + chain = chain[-1:] + elif self.test_certificates == 'first': + chain = chain[:1] + for cert in chain: + try: + x509 = cryptography.x509.load_pem_x509_certificate(to_bytes(cert), cryptography.hazmat.backends.default_backend()) + matches = True + if not self._match_subject(x509.subject, self.subject): + matches = False + if not self._match_subject(x509.issuer, self.issuer): + matches = False + if self.subject_key_identifier: + try: + ext = x509.extensions.get_extension_for_class(cryptography.x509.SubjectKeyIdentifier) + if self.subject_key_identifier != ext.value.digest: + matches = False + except cryptography.x509.ExtensionNotFound: + matches = False + if self.authority_key_identifier: + try: + ext = x509.extensions.get_extension_for_class(cryptography.x509.AuthorityKeyIdentifier) + if self.authority_key_identifier != ext.value.key_identifier: + matches = False + except cryptography.x509.ExtensionNotFound: + matches = False + if matches: + return True + except Exception as e: + self.module.warn('Error while loading certificate {0}: {1}'.format(cert, e)) + return False + + +class CryptographyBackend(CryptoBackend): + def __init__(self, module): + super(CryptographyBackend, self).__init__(module) + + 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. + Raises KeyParsingError in case of errors. + ''' + # If key_content is not given, read key_file + if key_content is None: + key_content = read_file(key_file) + else: + key_content = to_bytes(key_content) + # Parse key + try: + key = cryptography.hazmat.primitives.serialization.load_pem_private_key( + key_content, + password=to_bytes(passphrase) if passphrase is not None else None, + backend=_cryptography_backend) + except Exception as e: + raise KeyParsingError('error while loading key: {0}'.format(e)) + if isinstance(key, cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey): + pk = key.public_key().public_numbers() + return { + 'key_obj': key, + 'type': 'rsa', + 'alg': 'RS256', + 'jwk': { + "kty": "RSA", + "e": nopad_b64(_convert_int_to_bytes(_count_bytes(pk.e), pk.e)), + "n": nopad_b64(_convert_int_to_bytes(_count_bytes(pk.n), pk.n)), + }, + 'hash': 'sha256', + } + elif isinstance(key, cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey): + pk = key.public_key().public_numbers() + if pk.curve.name == 'secp256r1': + bits = 256 + alg = 'ES256' + hashalg = 'sha256' + point_size = 32 + curve = 'P-256' + elif pk.curve.name == 'secp384r1': + bits = 384 + alg = 'ES384' + hashalg = 'sha384' + point_size = 48 + curve = 'P-384' + elif pk.curve.name == 'secp521r1': + # Not yet supported on Let's Encrypt side, see + # https://github.com/letsencrypt/boulder/issues/2217 + bits = 521 + alg = 'ES512' + hashalg = 'sha512' + point_size = 66 + curve = 'P-521' + else: + raise KeyParsingError('unknown elliptic curve: {0}'.format(pk.curve.name)) + num_bytes = (bits + 7) // 8 + return { + 'key_obj': key, + 'type': 'ec', + 'alg': alg, + 'jwk': { + "kty": "EC", + "crv": curve, + "x": nopad_b64(_convert_int_to_bytes(num_bytes, pk.x)), + "y": nopad_b64(_convert_int_to_bytes(num_bytes, pk.y)), + }, + 'hash': hashalg, + 'point_size': point_size, + } + else: + raise KeyParsingError('unknown key type "{0}"'.format(type(key))) + + def sign(self, payload64, protected64, key_data): + sign_payload = "{0}.{1}".format(protected64, payload64).encode('utf8') + if 'mac_obj' in key_data: + mac = key_data['mac_obj']() + mac.update(sign_payload) + signature = mac.finalize() + elif isinstance(key_data['key_obj'], cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey): + padding = cryptography.hazmat.primitives.asymmetric.padding.PKCS1v15() + hashalg = cryptography.hazmat.primitives.hashes.SHA256 + signature = key_data['key_obj'].sign(sign_payload, padding, hashalg()) + elif isinstance(key_data['key_obj'], cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey): + if key_data['hash'] == 'sha256': + hashalg = cryptography.hazmat.primitives.hashes.SHA256 + elif key_data['hash'] == 'sha384': + hashalg = cryptography.hazmat.primitives.hashes.SHA384 + elif key_data['hash'] == 'sha512': + hashalg = cryptography.hazmat.primitives.hashes.SHA512 + ecdsa = cryptography.hazmat.primitives.asymmetric.ec.ECDSA(hashalg()) + r, s = cryptography.hazmat.primitives.asymmetric.utils.decode_dss_signature(key_data['key_obj'].sign(sign_payload, ecdsa)) + rr = _pad_hex(r, 2 * key_data['point_size']) + ss = _pad_hex(s, 2 * key_data['point_size']) + signature = binascii.unhexlify(rr) + binascii.unhexlify(ss) + + return { + "protected": protected64, + "payload": payload64, + "signature": nopad_b64(signature), + } + + def create_mac_key(self, alg, key): + '''Create a MAC key.''' + if alg == 'HS256': + hashalg = cryptography.hazmat.primitives.hashes.SHA256 + hashbytes = 32 + elif alg == 'HS384': + hashalg = cryptography.hazmat.primitives.hashes.SHA384 + hashbytes = 48 + elif alg == 'HS512': + hashalg = cryptography.hazmat.primitives.hashes.SHA512 + hashbytes = 64 + else: + raise BackendException('Unsupported MAC key algorithm for cryptography backend: {0}'.format(alg)) + key_bytes = base64.urlsafe_b64decode(key) + if len(key_bytes) < hashbytes: + raise BackendException( + '{0} key must be at least {1} bytes long (after Base64 decoding)'.format(alg, hashbytes)) + return { + 'mac_obj': lambda: cryptography.hazmat.primitives.hmac.HMAC( + key_bytes, + hashalg(), + _cryptography_backend), + 'type': 'hmac', + 'alg': alg, + 'jwk': { + 'kty': 'oct', + 'k': key, + }, + } + + def get_csr_identifiers(self, csr_filename=None, csr_content=None): + ''' + Return a set of requested identifiers (CN and SANs) for the CSR. + Each identifier is a pair (type, identifier), where type is either + 'dns' or 'ip'. + ''' + identifiers = set([]) + if csr_content is None: + csr_content = read_file(csr_filename) + else: + csr_content = to_bytes(csr_content) + csr = cryptography.x509.load_pem_x509_csr(csr_content, _cryptography_backend) + for sub in csr.subject: + if sub.oid == cryptography.x509.oid.NameOID.COMMON_NAME: + identifiers.add(('dns', sub.value)) + for extension in csr.extensions: + if extension.oid == cryptography.x509.oid.ExtensionOID.SUBJECT_ALTERNATIVE_NAME: + for name in extension.value: + if isinstance(name, cryptography.x509.DNSName): + identifiers.add(('dns', name.value)) + elif isinstance(name, cryptography.x509.IPAddress): + identifiers.add(('ip', name.value.compressed)) + else: + raise BackendException('Found unsupported SAN identifier {0}'.format(name)) + return identifiers + + def get_cert_days(self, cert_filename=None, cert_content=None, now=None): + ''' + Return the days the certificate in cert_filename remains valid and -1 + if the file was not found. If cert_filename contains more than one + certificate, only the first one will be considered. + + If now is not specified, datetime.datetime.now() is used. + ''' + if cert_filename is not None: + cert_content = None + if os.path.exists(cert_filename): + cert_content = read_file(cert_filename) + else: + cert_content = to_bytes(cert_content) + + if cert_content is None: + return -1 + + # 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)) + + if now is None: + now = datetime.datetime.now() + return (cert.not_valid_after - now).days + + def create_chain_matcher(self, criterium): + ''' + Given a Criterium object, creates a ChainMatcher object. + ''' + return CryptographyChainMatcher(criterium, self.module) 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 new file mode 100644 index 00000000..dabcbdb3 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/acme/backend_openssl_cli.py @@ -0,0 +1,302 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net> +# Copyright (c) 2021 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 base64 +import binascii +import datetime +import os +import re +import tempfile +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 ( + CryptoBackend, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ( + BackendException, + KeyParsingError, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.utils import nopad_b64 + +try: + import ipaddress +except ImportError: + pass + + +_OPENSSL_ENVIRONMENT_UPDATE = dict(LANG='C', LC_ALL='C', LC_MESSAGES='C', LC_CTYPE='C') + + +class OpenSSLCLIBackend(CryptoBackend): + def __init__(self, module, openssl_binary=None): + super(OpenSSLCLIBackend, self).__init__(module) + if openssl_binary is None: + openssl_binary = module.get_bin_path('openssl', True) + self.openssl_binary = openssl_binary + + 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. + Raises KeyParsingError in case of errors. + ''' + if passphrase is not None: + raise KeyParsingError('openssl backend does not support key passphrases') + # If key_file is not given, but key_content, write that to a temporary file + if key_file is None: + fd, tmpsrc = tempfile.mkstemp() + self.module.add_cleanup_file(tmpsrc) # Ansible will delete the file on exit + f = os.fdopen(fd, 'wb') + try: + f.write(key_content.encode('utf-8')) + key_file = tmpsrc + except Exception as err: + try: + f.close() + except Exception as dummy: + pass + raise KeyParsingError("failed to create temporary content file: %s" % to_native(err), exception=traceback.format_exc()) + f.close() + # Parse key + account_key_type = None + with open(key_file, "rt") as f: + for line in f: + m = re.match(r"^\s*-{5,}BEGIN\s+(EC|RSA)\s+PRIVATE\s+KEY-{5,}\s*$", line) + if m is not None: + account_key_type = m.group(1).lower() + break + if account_key_type is None: + # This happens for example if openssl_privatekey created this key + # (as opposed to the OpenSSL binary). For now, we assume this is + # an RSA key. + # FIXME: add some kind of auto-detection + account_key_type = "rsa" + if account_key_type not in ("rsa", "ec"): + raise KeyParsingError('unknown key type "%s"' % account_key_type) + + openssl_keydump_cmd = [self.openssl_binary, account_key_type, "-in", key_file, "-noout", "-text"] + dummy, out, dummy = self.module.run_command( + openssl_keydump_cmd, check_rc=True, environ_update=_OPENSSL_ENVIRONMENT_UPDATE) + + 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_exp = "{0:x}".format(int(pub_exp)) + if len(pub_exp) % 2: + pub_exp = "0{0}".format(pub_exp) + + return { + 'key_file': key_file, + 'type': 'rsa', + 'alg': 'RS256', + '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"))), + }, + '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) + 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")) + 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': + bits = 256 + alg = 'ES256' + hashalg = 'sha256' + point_size = 32 + curve = 'P-256' + elif asn1_oid_curve == 'secp384r1' or nist_curve == 'p-384': + bits = 384 + alg = 'ES384' + hashalg = 'sha384' + point_size = 48 + curve = 'P-384' + elif asn1_oid_curve == 'secp521r1' or nist_curve == 'p-521': + # Not yet supported on Let's Encrypt side, see + # https://github.com/letsencrypt/boulder/issues/2217 + bits = 521 + alg = 'ES512' + hashalg = 'sha512' + point_size = 66 + curve = 'P-521' + else: + raise KeyParsingError('unknown elliptic curve: %s / %s' % (asn1_oid_curve, nist_curve)) + num_bytes = (bits + 7) // 8 + if len(pub_hex) != 2 * num_bytes: + raise KeyParsingError('bad elliptic curve point (%s / %s)' % (asn1_oid_curve, nist_curve)) + return { + 'key_file': key_file, + 'type': 'ec', + 'alg': alg, + 'jwk': { + "kty": "EC", + "crv": curve, + "x": nopad_b64(pub_hex[:num_bytes]), + "y": nopad_b64(pub_hex[num_bytes:]), + }, + 'hash': hashalg, + 'point_size': point_size, + } + + def sign(self, payload64, protected64, key_data): + sign_payload = "{0}.{1}".format(protected64, payload64).encode('utf8') + if key_data['type'] == 'hmac': + hex_key = to_native(binascii.hexlify(base64.urlsafe_b64decode(key_data['jwk']['k']))) + cmd_postfix = ["-mac", "hmac", "-macopt", "hexkey:{0}".format(hex_key), "-binary"] + else: + cmd_postfix = ["-sign", key_data['key_file']] + openssl_sign_cmd = [self.openssl_binary, "dgst", "-{0}".format(key_data['hash'])] + cmd_postfix + + dummy, out, dummy = self.module.run_command( + openssl_sign_cmd, data=sign_payload, check_rc=True, binary_data=True, environ_update=_OPENSSL_ENVIRONMENT_UPDATE) + + if key_data['type'] == 'ec': + dummy, der_out, dummy = self.module.run_command( + [self.openssl_binary, "asn1parse", "-inform", "DER"], + data=out, binary_data=True, environ_update=_OPENSSL_ENVIRONMENT_UPDATE) + expected_len = 2 * key_data['point_size'] + sig = re.findall( + r"prim:\s+INTEGER\s+:([0-9A-F]{1,%s})\n" % expected_len, + to_text(der_out, errors='surrogate_or_strict')) + if len(sig) != 2: + raise BackendException( + "failed to generate Elliptic Curve signature; cannot parse DER output: {0}".format( + to_text(der_out, errors='surrogate_or_strict'))) + sig[0] = (expected_len - len(sig[0])) * '0' + sig[0] + sig[1] = (expected_len - len(sig[1])) * '0' + sig[1] + out = binascii.unhexlify(sig[0]) + binascii.unhexlify(sig[1]) + + return { + "protected": protected64, + "payload": payload64, + "signature": nopad_b64(to_bytes(out)), + } + + def create_mac_key(self, alg, key): + '''Create a MAC key.''' + if alg == 'HS256': + hashalg = 'sha256' + hashbytes = 32 + elif alg == 'HS384': + hashalg = 'sha384' + hashbytes = 48 + elif alg == 'HS512': + hashalg = 'sha512' + hashbytes = 64 + else: + raise BackendException('Unsupported MAC key algorithm for OpenSSL backend: {0}'.format(alg)) + key_bytes = base64.urlsafe_b64decode(key) + if len(key_bytes) < hashbytes: + raise BackendException( + '{0} key must be at least {1} bytes long (after Base64 decoding)'.format(alg, hashbytes)) + return { + 'type': 'hmac', + 'alg': alg, + 'jwk': { + 'kty': 'oct', + 'k': key, + }, + 'hash': hashalg, + } + + @staticmethod + def _normalize_ip(ip): + try: + return to_native(ipaddress.ip_address(to_text(ip)).compressed) + except ValueError: + # We do not want to error out on something IPAddress() cannot parse + return ip + + def get_csr_identifiers(self, csr_filename=None, csr_content=None): + ''' + Return a set of requested identifiers (CN and SANs) for the CSR. + Each identifier is a pair (type, identifier), where type is either + 'dns' or 'ip'. + ''' + filename = csr_filename + data = None + if csr_content is not None: + filename = '/dev/stdin' + data = csr_content.encode('utf-8') + + openssl_csr_cmd = [self.openssl_binary, "req", "-in", filename, "-noout", "-text"] + dummy, out, dummy = self.module.run_command( + openssl_csr_cmd, data=data, check_rc=True, binary_data=True, environ_update=_OPENSSL_ENVIRONMENT_UPDATE) + + identifiers = set([]) + common_name = re.search(r"Subject:.* CN\s?=\s?([^\s,;/]+)", to_text(out, errors='surrogate_or_strict')) + if common_name is not None: + identifiers.add(('dns', common_name.group(1))) + subject_alt_names = re.search( + r"X509v3 Subject Alternative Name: (?:critical)?\n +([^\n]+)\n", + to_text(out, errors='surrogate_or_strict'), re.MULTILINE | re.DOTALL) + if subject_alt_names is not None: + for san in subject_alt_names.group(1).split(", "): + if san.lower().startswith("dns:"): + identifiers.add(('dns', san[4:])) + elif san.lower().startswith("ip:"): + identifiers.add(('ip', self._normalize_ip(san[3:]))) + elif san.lower().startswith("ip address:"): + identifiers.add(('ip', self._normalize_ip(san[11:]))) + else: + raise BackendException('Found unsupported SAN identifier "{0}"'.format(san)) + return identifiers + + def get_cert_days(self, cert_filename=None, cert_content=None, now=None): + ''' + Return the days the certificate in cert_filename remains valid and -1 + if the file was not found. If cert_filename contains more than one + certificate, only the first one will be considered. + + If now is not specified, datetime.datetime.now() is used. + ''' + filename = cert_filename + data = None + if cert_content is not None: + filename = '/dev/stdin' + data = cert_content.encode('utf-8') + cert_filename_suffix = '' + elif cert_filename is not None: + if not os.path.exists(cert_filename): + return -1 + cert_filename_suffix = ' in {0}'.format(cert_filename) + else: + return -1 + + 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)) + if now is None: + now = datetime.datetime.now() + return (not_after - now).days + + def create_chain_matcher(self, criterium): + ''' + Given a Criterium object, creates a ChainMatcher object. + ''' + raise BackendException('Alternate chain matching can only be used with the "cryptography" backend.') diff --git a/ansible_collections/community/crypto/plugins/module_utils/acme/backends.py b/ansible_collections/community/crypto/plugins/module_utils/acme/backends.py new file mode 100644 index 00000000..5c48e1a7 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/acme/backends.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net> +# Copyright (c) 2021 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 abc + +from ansible.module_utils import six + + +@six.add_metaclass(abc.ABCMeta) +class CryptoBackend(object): + def __init__(self, module): + self.module = module + + @abc.abstractmethod + 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. + Raises KeyParsingError in case of errors. + ''' + + @abc.abstractmethod + def sign(self, payload64, protected64, key_data): + pass + + @abc.abstractmethod + def create_mac_key(self, alg, key): + '''Create a MAC key.''' + + @abc.abstractmethod + def get_csr_identifiers(self, csr_filename=None, csr_content=None): + ''' + Return a set of requested identifiers (CN and SANs) for the CSR. + Each identifier is a pair (type, identifier), where type is either + 'dns' or 'ip'. + ''' + + @abc.abstractmethod + def get_cert_days(self, cert_filename=None, cert_content=None, now=None): + ''' + Return the days the certificate in cert_filename remains valid and -1 + if the file was not found. If cert_filename contains more than one + certificate, only the first one will be considered. + + If now is not specified, datetime.datetime.now() is used. + ''' + + @abc.abstractmethod + def create_chain_matcher(self, criterium): + ''' + Given a Criterium object, creates a ChainMatcher object. + ''' diff --git a/ansible_collections/community/crypto/plugins/module_utils/acme/certificates.py b/ansible_collections/community/crypto/plugins/module_utils/acme/certificates.py new file mode 100644 index 00000000..29e5e185 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/acme/certificates.py @@ -0,0 +1,129 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net> +# Copyright (c) 2021 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 abc + +from ansible.module_utils import six + +from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ( + ModuleFailException, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.utils import ( + der_to_pem, + nopad_b64, + process_links, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.pem import ( + split_pem_list, +) + + +class CertificateChain(object): + ''' + Download and parse the certificate chain. + https://tools.ietf.org/html/rfc8555#section-7.4.2 + ''' + + def __init__(self, url): + self.url = url + self.cert = None + self.chain = [] + self.alternates = [] + + @classmethod + def download(cls, client, url): + content, info = client.get_request(url, parse_json_result=False, headers={'Accept': 'application/pem-certificate-chain'}) + + if not content or not info['content-type'].startswith('application/pem-certificate-chain'): + raise ModuleFailException( + "Cannot download certificate chain from {0}, as content type is not application/pem-certificate-chain: {1} (headers: {2})".format( + url, content, info)) + + result = cls(url) + + # Parse data + certs = split_pem_list(content.decode('utf-8'), keep_inbetween=True) + if certs: + result.cert = certs[0] + result.chain = certs[1:] + + process_links(info, lambda link, relation: result._process_links(client, link, relation)) + + if result.cert is None: + raise ModuleFailException("Failed to parse certificate chain download from {0}: {1} (headers: {2})".format(url, content, info)) + + return result + + def _process_links(self, client, link, relation): + if relation == 'up': + # Process link-up headers if there was no chain in reply + if not self.chain: + chain_result, chain_info = client.get_request(link, parse_json_result=False) + if chain_info['status'] in [200, 201]: + self.chain.append(der_to_pem(chain_result)) + elif relation == 'alternate': + self.alternates.append(link) + + def to_json(self): + cert = self.cert.encode('utf8') + chain = ('\n'.join(self.chain)).encode('utf8') + return { + 'cert': cert, + 'chain': chain, + 'full_chain': cert + chain, + } + + +class Criterium(object): + def __init__(self, criterium, index=None): + self.index = index + self.test_certificates = criterium['test_certificates'] + self.subject = criterium['subject'] + self.issuer = criterium['issuer'] + self.subject_key_identifier = criterium['subject_key_identifier'] + self.authority_key_identifier = criterium['authority_key_identifier'] + + +@six.add_metaclass(abc.ABCMeta) +class ChainMatcher(object): + @abc.abstractmethod + def match(self, certificate): + ''' + Check whether a certificate chain (CertificateChain instance) matches. + ''' + + +def retrieve_acme_v1_certificate(client, csr_der): + ''' + Create a new certificate based on the CSR (ACME v1 protocol). + Return the certificate object as dict + https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-6.5 + ''' + new_cert = { + "resource": "new-cert", + "csr": nopad_b64(csr_der), + } + result, info = client.send_signed_request( + client.directory['new-cert'], new_cert, error_msg='Failed to receive certificate', expected_status_codes=[200, 201]) + cert = CertificateChain(info['location']) + cert.cert = der_to_pem(result) + + def f(link, relation): + if relation == 'up': + chain_result, chain_info = client.get_request(link, parse_json_result=False) + if chain_info['status'] in [200, 201]: + del cert.chain[:] + cert.chain.append(der_to_pem(chain_result)) + + process_links(info, f) + return cert diff --git a/ansible_collections/community/crypto/plugins/module_utils/acme/challenges.py b/ansible_collections/community/crypto/plugins/module_utils/acme/challenges.py new file mode 100644 index 00000000..366fde54 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/acme/challenges.py @@ -0,0 +1,303 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net> +# Copyright (c) 2021 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 base64 +import hashlib +import json +import re +import time + +from ansible.module_utils.common.text.converters import to_bytes + +from ansible_collections.community.crypto.plugins.module_utils.acme.utils import ( + nopad_b64, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ( + format_error_problem, + ACMEProtocolException, + ModuleFailException, +) + +try: + import ipaddress +except ImportError: + pass + + +def create_key_authorization(client, token): + ''' + Returns the key authorization for the given token + https://tools.ietf.org/html/rfc8555#section-8.1 + ''' + accountkey_json = json.dumps(client.account_jwk, sort_keys=True, separators=(',', ':')) + thumbprint = nopad_b64(hashlib.sha256(accountkey_json.encode('utf8')).digest()) + return "{0}.{1}".format(token, thumbprint) + + +def combine_identifier(identifier_type, identifier): + return '{type}:{identifier}'.format(type=identifier_type, identifier=identifier) + + +def split_identifier(identifier): + parts = identifier.split(':', 1) + if len(parts) != 2: + raise ModuleFailException( + 'Identifier "{identifier}" is not of the form <type>:<identifier>'.format(identifier=identifier)) + return parts + + +class Challenge(object): + def __init__(self, data, url): + self.data = data + + self.type = data['type'] + self.url = url + self.status = data['status'] + self.token = data.get('token') + + @classmethod + def from_json(cls, client, data, url=None): + return cls(data, url or (data['uri'] if client.version == 1 else data['url'])) + + def call_validate(self, client): + challenge_response = {} + if client.version == 1: + token = re.sub(r"[^A-Za-z0-9_\-]", "_", self.token) + key_authorization = create_key_authorization(client, token) + challenge_response['resource'] = 'challenge' + challenge_response['keyAuthorization'] = key_authorization + challenge_response['type'] = self.type + client.send_signed_request( + self.url, + challenge_response, + error_msg='Failed to validate challenge', + expected_status_codes=[200, 202], + ) + + def to_json(self): + return self.data.copy() + + def get_validation_data(self, client, identifier_type, identifier): + token = re.sub(r"[^A-Za-z0-9_\-]", "_", self.token) + key_authorization = create_key_authorization(client, token) + + if self.type == 'http-01': + # https://tools.ietf.org/html/rfc8555#section-8.3 + return { + 'resource': '.well-known/acme-challenge/{token}'.format(token=token), + 'resource_value': key_authorization, + } + + if self.type == 'dns-01': + if identifier_type != 'dns': + return None + # 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) + return { + 'resource': resource, + 'resource_value': value, + 'record': record, + } + + if self.type == 'tls-alpn-01': + # https://www.rfc-editor.org/rfc/rfc8737.html#section-3 + if identifier_type == 'ip': + # IPv4/IPv6 address: use reverse mapping (RFC1034, RFC3596) + resource = ipaddress.ip_address(identifier).reverse_pointer + if not resource.endswith('.'): + resource += '.' + else: + resource = identifier + value = base64.b64encode(hashlib.sha256(to_bytes(key_authorization)).digest()) + return { + 'resource': resource, + 'resource_original': combine_identifier(identifier_type, identifier), + 'resource_value': value, + } + + # Unknown challenge type: ignore + return None + + +class Authorization(object): + def _setup(self, client, data): + data['uri'] = self.url + self.data = data + self.challenges = [Challenge.from_json(client, challenge) for challenge in data['challenges']] + if client.version == 1 and 'status' not in data: + # https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-6.1.2 + # "status (required, string): ... + # If this field is missing, then the default value is "pending"." + self.status = 'pending' + else: + self.status = data['status'] + self.identifier = data['identifier']['value'] + self.identifier_type = data['identifier']['type'] + if data.get('wildcard', False): + self.identifier = '*.{0}'.format(self.identifier) + + def __init__(self, url): + self.url = url + + self.data = None + self.challenges = [] + self.status = None + self.identifier_type = None + self.identifier = None + + @classmethod + def from_json(cls, client, data, url): + result = cls(url) + result._setup(client, data) + return result + + @classmethod + def from_url(cls, client, url): + result = cls(url) + result.refresh(client) + return result + + @classmethod + def create(cls, client, identifier_type, identifier): + ''' + Create a new authorization for the given identifier. + Return the authorization object of the new authorization + https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-6.4 + ''' + new_authz = { + "identifier": { + "type": identifier_type, + "value": identifier, + }, + } + if client.version == 1: + url = client.directory['new-authz'] + new_authz["resource"] = "new-authz" + else: + if 'newAuthz' not in client.directory.directory: + raise ACMEProtocolException(client.module, 'ACME endpoint does not support pre-authorization') + url = client.directory['newAuthz'] + + result, info = client.send_signed_request( + url, new_authz, error_msg='Failed to request challenges', expected_status_codes=[200, 201]) + return cls.from_json(client, result, info['location']) + + @property + def combined_identifier(self): + return combine_identifier(self.identifier_type, self.identifier) + + def to_json(self): + return self.data.copy() + + def refresh(self, client): + result, dummy = client.get_request(self.url) + changed = self.data != result + self._setup(client, result) + return changed + + def get_challenge_data(self, client): + ''' + Returns a dict with the data for all proposed (and supported) challenges + of the given authorization. + ''' + data = {} + for challenge in self.challenges: + validation_data = challenge.get_validation_data(client, self.identifier_type, self.identifier) + if validation_data is not None: + data[challenge.type] = validation_data + return data + + def raise_error(self, error_msg, module=None): + ''' + Aborts with a specific error for a challenge. + ''' + error_details = [] + # multiple challenges could have failed at this point, gather error + # details for all of them before failing + for challenge in self.challenges: + if challenge.status == 'invalid': + msg = 'Challenge {type}'.format(type=challenge.type) + if 'error' in challenge.data: + msg = '{msg}: {problem}'.format( + msg=msg, + problem=format_error_problem(challenge.data['error'], subproblem_prefix='{0}.'.format(challenge.type)), + ) + error_details.append(msg) + raise ACMEProtocolException( + module, + 'Failed to validate challenge for {identifier}: {error}. {details}'.format( + identifier=self.combined_identifier, + error=error_msg, + details='; '.join(error_details), + ), + extras=dict( + identifier=self.combined_identifier, + authorization=self.data, + ), + ) + + def find_challenge(self, challenge_type): + for challenge in self.challenges: + if challenge_type == challenge.type: + return challenge + return None + + def wait_for_validation(self, client, callenge_type): + while True: + self.refresh(client) + if self.status in ['valid', 'invalid', 'revoked']: + break + time.sleep(2) + + if self.status == 'invalid': + self.raise_error('Status is "invalid"', module=client.module) + + return self.status == 'valid' + + def call_validate(self, client, challenge_type, wait=True): + ''' + Validate the authorization provided in the auth dict. Returns True + when the validation was successful and False when it was not. + ''' + challenge = self.find_challenge(challenge_type) + if challenge is None: + raise ModuleFailException('Found no challenge of type "{challenge}" for identifier {identifier}!'.format( + challenge=challenge_type, + identifier=self.combined_identifier, + )) + + challenge.call_validate(client) + + if not wait: + return self.status == 'valid' + return self.wait_for_validation(client, challenge_type) + + 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': + return + authz_deactivate = { + 'status': 'deactivated' + } + if client.version == 1: + authz_deactivate['resource'] = 'authz' + result, info = client.send_signed_request(self.url, authz_deactivate, fail_on_error=False) + if 200 <= info['status'] < 300 and result.get('status') == 'deactivated': + self.status = 'deactivated' + return True + return False diff --git a/ansible_collections/community/crypto/plugins/module_utils/acme/errors.py b/ansible_collections/community/crypto/plugins/module_utils/acme/errors.py new file mode 100644 index 00000000..208a1ae4 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/acme/errors.py @@ -0,0 +1,145 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net> +# Copyright (c) 2021 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.common.text.converters import to_text +from ansible.module_utils.six import binary_type, PY3 +from ansible.module_utils.six.moves.http_client import responses as http_responses + + +def format_http_status(status_code): + expl = http_responses.get(status_code) + if not expl: + return str(status_code) + return '%d %s' % (status_code, expl) + + +def format_error_problem(problem, subproblem_prefix=''): + if 'title' in problem: + msg = 'Error "{title}" ({type})'.format( + type=problem['type'], + title=problem['title'], + ) + else: + msg = 'Error {type}'.format(type=problem['type']) + if 'detail' in problem: + msg += ': "{detail}"'.format(detail=problem['detail']) + subproblems = problem.get('subproblems') + if subproblems is not None: + msg = '{msg} Subproblems:'.format(msg=msg) + for index, problem in enumerate(subproblems): + index_str = '{prefix}{index}'.format(prefix=subproblem_prefix, index=index) + msg = '{msg}\n({index}) {problem}'.format( + msg=msg, + index=index_str, + problem=format_error_problem(problem, subproblem_prefix='{0}.'.format(index_str)), + ) + return msg + + +class ModuleFailException(Exception): + ''' + If raised, module.fail_json() will be called with the given parameters after cleanup. + ''' + def __init__(self, msg, **args): + super(ModuleFailException, self).__init__(self, msg) + self.msg = msg + self.module_fail_args = args + + def do_fail(self, module, **arguments): + module.fail_json(msg=self.msg, other=self.module_fail_args, **arguments) + + +class ACMEProtocolException(ModuleFailException): + def __init__(self, module, msg=None, info=None, response=None, content=None, content_json=None, extras=None): + # Try to get hold of content, if response is given and content is not provided + if content is None and content_json is None and response is not None: + try: + # In Python 2, reading from a closed response yields a TypeError. + # In Python 3, read() simply returns '' + if PY3 and response.closed: + raise TypeError + content = response.read() + except (AttributeError, TypeError): + content = info.pop('body', None) + + # Make sure that content_json is None or a dictionary + if content_json is not None and not isinstance(content_json, dict): + if content is None and isinstance(content_json, binary_type): + content = content_json + content_json = None + + # Try to get hold of JSON decoded content, when content is given and JSON not provided + if content_json is None and content is not None and module is not None: + try: + content_json = module.from_json(to_text(content)) + except Exception as e: + pass + + extras = extras or dict() + + if msg is None: + msg = 'ACME request failed' + add_msg = '' + + if info is not None: + url = info['url'] + code = info['status'] + extras['http_url'] = url + extras['http_status'] = code + if code is not None and code >= 400 and content_json is not None and 'type' in content_json: + if 'status' in content_json and content_json['status'] != code: + code = 'status {problem_code} (HTTP status: {http_code})'.format( + http_code=format_http_status(code), problem_code=content_json['status']) + else: + code = 'status {problem_code}'.format(problem_code=format_http_status(code)) + subproblems = content_json.pop('subproblems', None) + add_msg = ' {problem}.'.format(problem=format_error_problem(content_json)) + extras['problem'] = content_json + extras['subproblems'] = subproblems or [] + if subproblems is not None: + add_msg = '{add_msg} Subproblems:'.format(add_msg=add_msg) + for index, problem in enumerate(subproblems): + add_msg = '{add_msg}\n({index}) {problem}.'.format( + add_msg=add_msg, + index=index, + problem=format_error_problem(problem, subproblem_prefix='{0}.'.format(index)), + ) + else: + code = 'HTTP status {code}'.format(code=format_http_status(code)) + if content_json is not None: + add_msg = ' The JSON error result: {content}'.format(content=content_json) + elif content is not None: + add_msg = ' The raw error result: {content}'.format(content=to_text(content)) + msg = '{msg} for {url} with {code}'.format(msg=msg, url=url, code=format_http_status(code)) + elif content_json is not None: + add_msg = ' The JSON result: {content}'.format(content=content_json) + elif content is not None: + add_msg = ' The raw result: {content}'.format(content=to_text(content)) + + super(ACMEProtocolException, self).__init__( + '{msg}.{add_msg}'.format(msg=msg, add_msg=add_msg), + **extras + ) + self.problem = {} + self.subproblems = [] + for k, v in extras.items(): + setattr(self, k, v) + + +class BackendException(ModuleFailException): + pass + + +class NetworkException(ModuleFailException): + pass + + +class KeyParsingError(ModuleFailException): + pass diff --git a/ansible_collections/community/crypto/plugins/module_utils/acme/io.py b/ansible_collections/community/crypto/plugins/module_utils/acme/io.py new file mode 100644 index 00000000..898d5a3d --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/acme/io.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2013, Romeo Theriault <romeot () hawaii.edu> +# Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net> +# Copyright (c) 2021 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 os +import shutil +import tempfile +import traceback + +from ansible.module_utils.common.text.converters import to_native + +from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ModuleFailException + + +def read_file(fn, mode='b'): + try: + with open(fn, 'r' + mode) as f: + return f.read() + except Exception as e: + raise ModuleFailException('Error while reading file "{0}": {1}'.format(fn, e)) + + +# This function was adapted from an earlier version of https://github.com/ansible/ansible/blob/devel/lib/ansible/modules/uri.py +def write_file(module, dest, content): + ''' + Write content to destination file dest, only if the content + has changed. + ''' + changed = False + # create a tempfile + fd, tmpsrc = tempfile.mkstemp(text=False) + f = os.fdopen(fd, 'wb') + try: + f.write(content) + except Exception as err: + try: + f.close() + except Exception as dummy: + pass + os.remove(tmpsrc) + raise ModuleFailException("failed to create temporary content file: %s" % to_native(err), exception=traceback.format_exc()) + f.close() + checksum_src = None + checksum_dest = None + # raise an error if there is no tmpsrc file + if not os.path.exists(tmpsrc): + try: + os.remove(tmpsrc) + except Exception as dummy: + pass + raise ModuleFailException("Source %s does not exist" % (tmpsrc)) + if not os.access(tmpsrc, os.R_OK): + os.remove(tmpsrc) + raise ModuleFailException("Source %s not readable" % (tmpsrc)) + checksum_src = module.sha1(tmpsrc) + # check if there is no dest file + if os.path.exists(dest): + # raise an error if copy has no permission on dest + if not os.access(dest, os.W_OK): + os.remove(tmpsrc) + raise ModuleFailException("Destination %s not writable" % (dest)) + if not os.access(dest, os.R_OK): + os.remove(tmpsrc) + raise ModuleFailException("Destination %s not readable" % (dest)) + checksum_dest = module.sha1(dest) + else: + dirname = os.path.dirname(dest) or '.' + if not os.access(dirname, os.W_OK): + os.remove(tmpsrc) + raise ModuleFailException("Destination dir %s not writable" % (dirname)) + if checksum_src != checksum_dest: + try: + shutil.copyfile(tmpsrc, dest) + changed = True + except Exception as err: + os.remove(tmpsrc) + raise ModuleFailException("failed to copy %s to %s: %s" % (tmpsrc, dest, to_native(err)), exception=traceback.format_exc()) + os.remove(tmpsrc) + return changed diff --git a/ansible_collections/community/crypto/plugins/module_utils/acme/orders.py b/ansible_collections/community/crypto/plugins/module_utils/acme/orders.py new file mode 100644 index 00000000..732b430d --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/acme/orders.py @@ -0,0 +1,130 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net> +# Copyright (c) 2021 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 time + +from ansible_collections.community.crypto.plugins.module_utils.acme.utils import ( + nopad_b64, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ( + ACMEProtocolException, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.challenges import ( + Authorization, +) + + +class Order(object): + def _setup(self, client, data): + self.data = data + + self.status = data['status'] + self.identifiers = [] + for identifier in data['identifiers']: + self.identifiers.append((identifier['type'], identifier['value'])) + self.finalize_uri = data.get('finalize') + self.certificate_uri = data.get('certificate') + self.authorization_uris = data['authorizations'] + self.authorizations = {} + + def __init__(self, url): + self.url = url + + self.data = None + + self.status = None + self.identifiers = [] + self.finalize_uri = None + self.certificate_uri = None + self.authorization_uris = [] + self.authorizations = {} + + @classmethod + def from_json(cls, client, data, url): + result = cls(url) + result._setup(client, data) + return result + + @classmethod + def from_url(cls, client, url): + result = cls(url) + result.refresh(client) + return result + + @classmethod + def create(cls, client, identifiers): + ''' + Start a new certificate order (ACME v2 protocol). + https://tools.ietf.org/html/rfc8555#section-7.4 + ''' + acme_identifiers = [] + for identifier_type, identifier in identifiers: + acme_identifiers.append({ + 'type': identifier_type, + 'value': identifier, + }) + new_order = { + "identifiers": acme_identifiers + } + 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']) + + def refresh(self, client): + result, dummy = client.get_request(self.url) + changed = self.data != result + self._setup(client, result) + return changed + + def load_authorizations(self, client): + for auth_uri in self.authorization_uris: + authz = Authorization.from_url(client, auth_uri) + self.authorizations[authz.combined_identifier] = authz + + def wait_for_finalization(self, client): + while True: + self.refresh(client) + if self.status in ['valid', 'invalid', 'pending', 'ready']: + break + time.sleep(2) + + if self.status != 'valid': + raise ACMEProtocolException( + client.module, + 'Failed to wait for order to complete; got status "{status}"'.format(status=self.status), + content_json=self.data) + + def finalize(self, client, csr_der, wait=True): + ''' + Create a new certificate based on the csr. + Return the certificate object as dict + https://tools.ietf.org/html/rfc8555#section-7.4 + ''' + new_cert = { + "csr": nopad_b64(csr_der), + } + result, info = client.send_signed_request( + self.finalize_uri, new_cert, error_msg='Failed to finalizing order', expected_status_codes=[200]) + # It is not clear from the RFC whether the finalize call returns the order object or not. + # Instead of using the result, we call self.refresh(client) below. + + if wait: + self.wait_for_finalization(client) + else: + self.refresh(client) + if self.status not in ['procesing', 'valid', 'invalid']: + raise ACMEProtocolException( + client.module, + 'Failed to finalize order; got status "{status}"'.format(status=self.status), + info=info, + content_json=result) diff --git a/ansible_collections/community/crypto/plugins/module_utils/acme/utils.py b/ansible_collections/community/crypto/plugins/module_utils/acme/utils.py new file mode 100644 index 00000000..217b6de4 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/acme/utils.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net> +# Copyright (c) 2021 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 base64 +import re +import textwrap +import traceback + +from ansible.module_utils.common.text.converters import to_native +from ansible.module_utils.six.moves.urllib.parse import unquote + +from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ModuleFailException + + +def nopad_b64(data): + return base64.urlsafe_b64encode(data).decode('utf8').replace("=", "") + + +def der_to_pem(der_cert): + ''' + Convert the DER format certificate in der_cert to a PEM format certificate and return it. + ''' + return """-----BEGIN CERTIFICATE-----\n{0}\n-----END CERTIFICATE-----\n""".format( + "\n".join(textwrap.wrap(base64.b64encode(der_cert).decode('utf8'), 64))) + + +def pem_to_der(pem_filename=None, pem_content=None): + ''' + Load PEM file, or use PEM file's content, and convert to DER. + + If PEM contains multiple entities, the first entity will be used. + ''' + certificate_lines = [] + if pem_content is not None: + lines = pem_content.splitlines() + elif pem_filename is not None: + try: + with open(pem_filename, "rt") as f: + lines = list(f) + except Exception as err: + raise ModuleFailException("cannot load PEM file {0}: {1}".format(pem_filename, to_native(err)), exception=traceback.format_exc()) + else: + raise ModuleFailException('One of pem_filename and pem_content must be provided') + header_line_count = 0 + for line in lines: + if line.startswith('-----'): + header_line_count += 1 + if header_line_count == 2: + # If certificate file contains other certs appended + # (like intermediate certificates), ignore these. + break + continue + certificate_lines.append(line.strip()) + return base64.b64decode(''.join(certificate_lines)) + + +def process_links(info, callback): + ''' + Process link header, calls callback for every link header with the URL and relation as options. + ''' + if 'link' in info: + link = info['link'] + for url, relation in re.findall(r'<([^>]+)>;\s*rel="(\w+)"', link): + callback(unquote(url), relation) diff --git a/ansible_collections/community/crypto/plugins/module_utils/crypto/_asn1.py b/ansible_collections/community/crypto/plugins/module_utils/crypto/_asn1.py new file mode 100644 index 00000000..e99b75a5 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/crypto/_asn1.py @@ -0,0 +1,154 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2020, Jordan Borean <jborean93@gmail.com> +# 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 re + +from ansible.module_utils.common.text.converters import to_bytes + + +""" +An ASN.1 serialized as a string in the OpenSSL format: + [modifier,]type[:value] + +modifier: + The modifier can be 'IMPLICIT:<tag_number><tag_class>,' or 'EXPLICIT:<tag_number><tag_class>' where IMPLICIT + changes the tag of the universal value to encode and EXPLICIT prefixes its tag to the existing universal value. + The tag_number must be set while the tag_class can be 'U', 'A', 'P', or 'C" for 'Universal', 'Application', + 'Private', or 'Context Specific' with C being the default. + +type: + The underlying ASN.1 type of the value specified. Currently only the following have been implemented: + UTF8: The value must be a UTF-8 encoded string. + +value: + The value to encode, the format of this value depends on the <type> specified. +""" +ASN1_STRING_REGEX = re.compile(r'^((?P<tag_type>IMPLICIT|EXPLICIT):(?P<tag_number>\d+)(?P<tag_class>U|A|P|C)?,)?' + r'(?P<value_type>[\w\d]+):(?P<value>.*)') + + +class TagClass: + universal = 0 + application = 1 + context_specific = 2 + private = 3 + + +# Universal tag numbers that can be encoded. +class TagNumber: + utf8_string = 12 + + +def _pack_octet_integer(value): + """ Packs an integer value into 1 or multiple octets. """ + # NOTE: This is *NOT* the same as packing an ASN.1 INTEGER like value. + octets = bytearray() + + # Continue to shift the number by 7 bits and pack into an octet until the + # value is fully packed. + while value: + octet_value = value & 0b01111111 + + # First round (last octet) must have the MSB set. + if len(octets): + octet_value |= 0b10000000 + + octets.append(octet_value) + value >>= 7 + + # Reverse to ensure the higher order octets are first. + octets.reverse() + return bytes(octets) + + +def serialize_asn1_string_as_der(value): + """ Deserializes an ASN.1 string to a DER encoded byte string. """ + asn1_match = ASN1_STRING_REGEX.match(value) + if not asn1_match: + raise ValueError("The ASN.1 serialized string must be in the format [modifier,]type[:value]") + + tag_type = asn1_match.group('tag_type') + tag_number = asn1_match.group('tag_number') + tag_class = asn1_match.group('tag_class') or 'C' + value_type = asn1_match.group('value_type') + asn1_value = asn1_match.group('value') + + if value_type != 'UTF8': + raise ValueError('The ASN.1 serialized string is not a known type "{0}", only UTF8 types are ' + 'supported'.format(value_type)) + + b_value = to_bytes(asn1_value, encoding='utf-8', errors='surrogate_or_strict') + + # We should only do a universal type tag if not IMPLICITLY tagged or the tag class is not universal. + if not tag_type or (tag_type == 'EXPLICIT' and tag_class != 'U'): + b_value = pack_asn1(TagClass.universal, False, TagNumber.utf8_string, b_value) + + if tag_type: + tag_class = { + 'U': TagClass.universal, + 'A': TagClass.application, + 'P': TagClass.private, + 'C': TagClass.context_specific, + }[tag_class] + + # When adding support for more types this should be looked into further. For now it works with UTF8Strings. + constructed = tag_type == 'EXPLICIT' and tag_class != TagClass.universal + b_value = pack_asn1(tag_class, constructed, int(tag_number), b_value) + + return b_value + + +def pack_asn1(tag_class, constructed, tag_number, b_data): + """Pack the value into an ASN.1 data structure. + + The structure for an ASN.1 element is + + | Identifier Octet(s) | Length Octet(s) | Data Octet(s) | + """ + b_asn1_data = bytearray() + + if tag_class < 0 or tag_class > 3: + raise ValueError("tag_class must be between 0 and 3 not %s" % tag_class) + + # Bit 8 and 7 denotes the class. + identifier_octets = tag_class << 6 + # Bit 6 denotes whether the value is primitive or constructed. + identifier_octets |= ((1 if constructed else 0) << 5) + + # Bits 5-1 contain the tag number, if it cannot be encoded in these 5 bits + # then they are set and another octet(s) is used to denote the tag number. + if tag_number < 31: + identifier_octets |= tag_number + b_asn1_data.append(identifier_octets) + else: + identifier_octets |= 31 + b_asn1_data.append(identifier_octets) + b_asn1_data.extend(_pack_octet_integer(tag_number)) + + length = len(b_data) + + # If the length can be encoded in 7 bits only 1 octet is required. + if length < 128: + b_asn1_data.append(length) + + else: + # Otherwise the length must be encoded across multiple octets + length_octets = bytearray() + while length: + length_octets.append(length & 0b11111111) + length >>= 8 + + length_octets.reverse() # Reverse to make the higher octets first. + + # The first length octet must have the MSB set alongside the number of + # octets the length was encoded in. + b_asn1_data.append(len(length_octets) | 0b10000000) + b_asn1_data.extend(length_octets) + + return bytes(b_asn1_data) + b_data diff --git a/ansible_collections/community/crypto/plugins/module_utils/crypto/_obj2txt.py b/ansible_collections/community/crypto/plugins/module_utils/crypto/_obj2txt.py new file mode 100644 index 00000000..1ac28367 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/crypto/_obj2txt.py @@ -0,0 +1,57 @@ +# This code is part of Ansible, but is an independent component. +# This particular file snippet, and this file snippet only, is licensed under the +# Apache 2.0 License. Modules you write using this snippet, which is embedded +# dynamically by Ansible, still belong to the author of the module, and may assign +# their own license to the complete work. + +# This excerpt is dual licensed under the terms of the Apache License, Version +# 2.0, and the BSD License. See the LICENSE file at +# https://github.com/pyca/cryptography/blob/master/LICENSE for complete details. +# +# The Apache 2.0 license has been included as LICENSES/Apache-2.0.txt in this collection. +# The BSD License license has been included as LICENSES/BSD-3-Clause.txt in this collection. +# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause +# +# Adapted from cryptography's hazmat/backends/openssl/decode_asn1.py +# +# Copyright (c) 2015, 2016 Paul Kehrer (@reaperhulk) +# Copyright (c) 2017 Fraser Tweedale (@frasertweedale) + +# Relevant commits from cryptography project (https://github.com/pyca/cryptography): +# pyca/cryptography@719d536dd691e84e208534798f2eb4f82aaa2e07 +# pyca/cryptography@5ab6d6a5c05572bd1c75f05baf264a2d0001894a +# pyca/cryptography@2e776e20eb60378e0af9b7439000d0e80da7c7e3 +# pyca/cryptography@fb309ed24647d1be9e319b61b1f2aa8ebb87b90b +# pyca/cryptography@2917e460993c475c72d7146c50dc3bbc2414280d +# pyca/cryptography@3057f91ea9a05fb593825006d87a391286a4d828 +# pyca/cryptography@d607dd7e5bc5c08854ec0c9baff70ba4a35be36f + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +# WARNING: this function no longer works with cryptography 35.0.0 and newer! +# It must **ONLY** be used in compatibility code for older +# cryptography versions! + +def obj2txt(openssl_lib, openssl_ffi, obj): + # Set to 80 on the recommendation of + # https://www.openssl.org/docs/crypto/OBJ_nid2ln.html#return_values + # + # But OIDs longer than this occur in real life (e.g. Active + # Directory makes some very long OIDs). So we need to detect + # and properly handle the case where the default buffer is not + # big enough. + # + buf_len = 80 + buf = openssl_ffi.new("char[]", buf_len) + + # 'res' is the number of bytes that *would* be written if the + # buffer is large enough. If 'res' > buf_len - 1, we need to + # alloc a big-enough buffer and go again. + res = openssl_lib.OBJ_obj2txt(buf, buf_len, obj, 1) + if res > buf_len - 1: # account for terminating null byte + buf_len = res + 1 + buf = openssl_ffi.new("char[]", buf_len) + res = openssl_lib.OBJ_obj2txt(buf, buf_len, obj, 1) + return openssl_ffi.buffer(buf, res)[:].decode() diff --git a/ansible_collections/community/crypto/plugins/module_utils/crypto/_objects.py b/ansible_collections/community/crypto/plugins/module_utils/crypto/_objects.py new file mode 100644 index 00000000..ed225805 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/crypto/_objects.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2019, 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 ._objects_data import OID_MAP + +OID_LOOKUP = dict() +NORMALIZE_NAMES = dict() +NORMALIZE_NAMES_SHORT = dict() + +for dotted, names in OID_MAP.items(): + for name in names: + if name in NORMALIZE_NAMES and OID_LOOKUP[name] != dotted: + raise AssertionError( + 'Name collision during setup: "{0}" for OIDs {1} and {2}' + .format(name, dotted, OID_LOOKUP[name]) + ) + NORMALIZE_NAMES[name] = names[0] + NORMALIZE_NAMES_SHORT[name] = names[-1] + OID_LOOKUP[name] = dotted +for alias, original in [('userID', 'userId')]: + if alias in NORMALIZE_NAMES: + raise AssertionError( + 'Name collision during adding aliases: "{0}" (alias for "{1}") is already mapped to OID {2}' + .format(alias, original, OID_LOOKUP[alias]) + ) + NORMALIZE_NAMES[alias] = original + NORMALIZE_NAMES_SHORT[alias] = NORMALIZE_NAMES_SHORT[original] + OID_LOOKUP[alias] = OID_LOOKUP[original] diff --git a/ansible_collections/community/crypto/plugins/module_utils/crypto/_objects_data.py b/ansible_collections/community/crypto/plugins/module_utils/crypto/_objects_data.py new file mode 100644 index 00000000..4d57b2ef --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/crypto/_objects_data.py @@ -0,0 +1,1115 @@ +# This code is part of Ansible, but is an independent component. +# This particular file snippet, and this file snippet only, is licensed under the +# Apache 2.0 License. Modules you write using this snippet, which is embedded +# dynamically by Ansible, still belong to the author of the module, and may assign +# their own license to the complete work. + +# This has been extracted from the OpenSSL project's objects.txt: +# https://github.com/openssl/openssl/blob/9537fe5757bb07761fa275d779bbd40bcf5530e4/crypto/objects/objects.txt +# Extracted with https://gist.github.com/felixfontein/376748017ad65ead093d56a45a5bf376 + +# In case the following data structure has any copyrightable content, note that it is licensed as follows: +# Copyright (c) the OpenSSL contributors +# Licensed under the Apache License 2.0 +# SPDX-License-Identifier: Apache-2.0 +# https://github.com/openssl/openssl/blob/master/LICENSE.txt or LICENSES/Apache-2.0.txt + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +OID_MAP = { + '0': ('itu-t', 'ITU-T', 'ccitt'), + '0.3.4401.5': ('ntt-ds', ), + '0.3.4401.5.3.1.9': ('camellia', ), + '0.3.4401.5.3.1.9.1': ('camellia-128-ecb', 'CAMELLIA-128-ECB'), + '0.3.4401.5.3.1.9.3': ('camellia-128-ofb', 'CAMELLIA-128-OFB'), + '0.3.4401.5.3.1.9.4': ('camellia-128-cfb', 'CAMELLIA-128-CFB'), + '0.3.4401.5.3.1.9.6': ('camellia-128-gcm', 'CAMELLIA-128-GCM'), + '0.3.4401.5.3.1.9.7': ('camellia-128-ccm', 'CAMELLIA-128-CCM'), + '0.3.4401.5.3.1.9.9': ('camellia-128-ctr', 'CAMELLIA-128-CTR'), + '0.3.4401.5.3.1.9.10': ('camellia-128-cmac', 'CAMELLIA-128-CMAC'), + '0.3.4401.5.3.1.9.21': ('camellia-192-ecb', 'CAMELLIA-192-ECB'), + '0.3.4401.5.3.1.9.23': ('camellia-192-ofb', 'CAMELLIA-192-OFB'), + '0.3.4401.5.3.1.9.24': ('camellia-192-cfb', 'CAMELLIA-192-CFB'), + '0.3.4401.5.3.1.9.26': ('camellia-192-gcm', 'CAMELLIA-192-GCM'), + '0.3.4401.5.3.1.9.27': ('camellia-192-ccm', 'CAMELLIA-192-CCM'), + '0.3.4401.5.3.1.9.29': ('camellia-192-ctr', 'CAMELLIA-192-CTR'), + '0.3.4401.5.3.1.9.30': ('camellia-192-cmac', 'CAMELLIA-192-CMAC'), + '0.3.4401.5.3.1.9.41': ('camellia-256-ecb', 'CAMELLIA-256-ECB'), + '0.3.4401.5.3.1.9.43': ('camellia-256-ofb', 'CAMELLIA-256-OFB'), + '0.3.4401.5.3.1.9.44': ('camellia-256-cfb', 'CAMELLIA-256-CFB'), + '0.3.4401.5.3.1.9.46': ('camellia-256-gcm', 'CAMELLIA-256-GCM'), + '0.3.4401.5.3.1.9.47': ('camellia-256-ccm', 'CAMELLIA-256-CCM'), + '0.3.4401.5.3.1.9.49': ('camellia-256-ctr', 'CAMELLIA-256-CTR'), + '0.3.4401.5.3.1.9.50': ('camellia-256-cmac', 'CAMELLIA-256-CMAC'), + '0.9': ('data', ), + '0.9.2342': ('pss', ), + '0.9.2342.19200300': ('ucl', ), + '0.9.2342.19200300.100': ('pilot', ), + '0.9.2342.19200300.100.1': ('pilotAttributeType', ), + '0.9.2342.19200300.100.1.1': ('userId', 'UID'), + '0.9.2342.19200300.100.1.2': ('textEncodedORAddress', ), + '0.9.2342.19200300.100.1.3': ('rfc822Mailbox', 'mail'), + '0.9.2342.19200300.100.1.4': ('info', ), + '0.9.2342.19200300.100.1.5': ('favouriteDrink', ), + '0.9.2342.19200300.100.1.6': ('roomNumber', ), + '0.9.2342.19200300.100.1.7': ('photo', ), + '0.9.2342.19200300.100.1.8': ('userClass', ), + '0.9.2342.19200300.100.1.9': ('host', ), + '0.9.2342.19200300.100.1.10': ('manager', ), + '0.9.2342.19200300.100.1.11': ('documentIdentifier', ), + '0.9.2342.19200300.100.1.12': ('documentTitle', ), + '0.9.2342.19200300.100.1.13': ('documentVersion', ), + '0.9.2342.19200300.100.1.14': ('documentAuthor', ), + '0.9.2342.19200300.100.1.15': ('documentLocation', ), + '0.9.2342.19200300.100.1.20': ('homeTelephoneNumber', ), + '0.9.2342.19200300.100.1.21': ('secretary', ), + '0.9.2342.19200300.100.1.22': ('otherMailbox', ), + '0.9.2342.19200300.100.1.23': ('lastModifiedTime', ), + '0.9.2342.19200300.100.1.24': ('lastModifiedBy', ), + '0.9.2342.19200300.100.1.25': ('domainComponent', 'DC'), + '0.9.2342.19200300.100.1.26': ('aRecord', ), + '0.9.2342.19200300.100.1.27': ('pilotAttributeType27', ), + '0.9.2342.19200300.100.1.28': ('mXRecord', ), + '0.9.2342.19200300.100.1.29': ('nSRecord', ), + '0.9.2342.19200300.100.1.30': ('sOARecord', ), + '0.9.2342.19200300.100.1.31': ('cNAMERecord', ), + '0.9.2342.19200300.100.1.37': ('associatedDomain', ), + '0.9.2342.19200300.100.1.38': ('associatedName', ), + '0.9.2342.19200300.100.1.39': ('homePostalAddress', ), + '0.9.2342.19200300.100.1.40': ('personalTitle', ), + '0.9.2342.19200300.100.1.41': ('mobileTelephoneNumber', ), + '0.9.2342.19200300.100.1.42': ('pagerTelephoneNumber', ), + '0.9.2342.19200300.100.1.43': ('friendlyCountryName', ), + '0.9.2342.19200300.100.1.44': ('uniqueIdentifier', 'uid'), + '0.9.2342.19200300.100.1.45': ('organizationalStatus', ), + '0.9.2342.19200300.100.1.46': ('janetMailbox', ), + '0.9.2342.19200300.100.1.47': ('mailPreferenceOption', ), + '0.9.2342.19200300.100.1.48': ('buildingName', ), + '0.9.2342.19200300.100.1.49': ('dSAQuality', ), + '0.9.2342.19200300.100.1.50': ('singleLevelQuality', ), + '0.9.2342.19200300.100.1.51': ('subtreeMinimumQuality', ), + '0.9.2342.19200300.100.1.52': ('subtreeMaximumQuality', ), + '0.9.2342.19200300.100.1.53': ('personalSignature', ), + '0.9.2342.19200300.100.1.54': ('dITRedirect', ), + '0.9.2342.19200300.100.1.55': ('audio', ), + '0.9.2342.19200300.100.1.56': ('documentPublisher', ), + '0.9.2342.19200300.100.3': ('pilotAttributeSyntax', ), + '0.9.2342.19200300.100.3.4': ('iA5StringSyntax', ), + '0.9.2342.19200300.100.3.5': ('caseIgnoreIA5StringSyntax', ), + '0.9.2342.19200300.100.4': ('pilotObjectClass', ), + '0.9.2342.19200300.100.4.3': ('pilotObject', ), + '0.9.2342.19200300.100.4.4': ('pilotPerson', ), + '0.9.2342.19200300.100.4.5': ('account', ), + '0.9.2342.19200300.100.4.6': ('document', ), + '0.9.2342.19200300.100.4.7': ('room', ), + '0.9.2342.19200300.100.4.9': ('documentSeries', ), + '0.9.2342.19200300.100.4.13': ('Domain', 'domain'), + '0.9.2342.19200300.100.4.14': ('rFC822localPart', ), + '0.9.2342.19200300.100.4.15': ('dNSDomain', ), + '0.9.2342.19200300.100.4.17': ('domainRelatedObject', ), + '0.9.2342.19200300.100.4.18': ('friendlyCountry', ), + '0.9.2342.19200300.100.4.19': ('simpleSecurityObject', ), + '0.9.2342.19200300.100.4.20': ('pilotOrganization', ), + '0.9.2342.19200300.100.4.21': ('pilotDSA', ), + '0.9.2342.19200300.100.4.22': ('qualityLabelledData', ), + '0.9.2342.19200300.100.10': ('pilotGroups', ), + '1': ('iso', 'ISO'), + '1.0.9797.3.4': ('gmac', 'GMAC'), + '1.0.10118.3.0.55': ('whirlpool', ), + '1.2': ('ISO Member Body', 'member-body'), + '1.2.156': ('ISO CN Member Body', 'ISO-CN'), + '1.2.156.10197': ('oscca', ), + '1.2.156.10197.1': ('sm-scheme', ), + '1.2.156.10197.1.104.1': ('sm4-ecb', 'SM4-ECB'), + '1.2.156.10197.1.104.2': ('sm4-cbc', 'SM4-CBC'), + '1.2.156.10197.1.104.3': ('sm4-ofb', 'SM4-OFB'), + '1.2.156.10197.1.104.4': ('sm4-cfb', 'SM4-CFB'), + '1.2.156.10197.1.104.5': ('sm4-cfb1', 'SM4-CFB1'), + '1.2.156.10197.1.104.6': ('sm4-cfb8', 'SM4-CFB8'), + '1.2.156.10197.1.104.7': ('sm4-ctr', 'SM4-CTR'), + '1.2.156.10197.1.301': ('sm2', 'SM2'), + '1.2.156.10197.1.401': ('sm3', 'SM3'), + '1.2.156.10197.1.501': ('SM2-with-SM3', 'SM2-SM3'), + '1.2.156.10197.1.504': ('sm3WithRSAEncryption', 'RSA-SM3'), + '1.2.392.200011.61.1.1.1.2': ('camellia-128-cbc', 'CAMELLIA-128-CBC'), + '1.2.392.200011.61.1.1.1.3': ('camellia-192-cbc', 'CAMELLIA-192-CBC'), + '1.2.392.200011.61.1.1.1.4': ('camellia-256-cbc', 'CAMELLIA-256-CBC'), + '1.2.392.200011.61.1.1.3.2': ('id-camellia128-wrap', ), + '1.2.392.200011.61.1.1.3.3': ('id-camellia192-wrap', ), + '1.2.392.200011.61.1.1.3.4': ('id-camellia256-wrap', ), + '1.2.410.200004': ('kisa', 'KISA'), + '1.2.410.200004.1.3': ('seed-ecb', 'SEED-ECB'), + '1.2.410.200004.1.4': ('seed-cbc', 'SEED-CBC'), + '1.2.410.200004.1.5': ('seed-cfb', 'SEED-CFB'), + '1.2.410.200004.1.6': ('seed-ofb', 'SEED-OFB'), + '1.2.410.200046.1.1': ('aria', ), + '1.2.410.200046.1.1.1': ('aria-128-ecb', 'ARIA-128-ECB'), + '1.2.410.200046.1.1.2': ('aria-128-cbc', 'ARIA-128-CBC'), + '1.2.410.200046.1.1.3': ('aria-128-cfb', 'ARIA-128-CFB'), + '1.2.410.200046.1.1.4': ('aria-128-ofb', 'ARIA-128-OFB'), + '1.2.410.200046.1.1.5': ('aria-128-ctr', 'ARIA-128-CTR'), + '1.2.410.200046.1.1.6': ('aria-192-ecb', 'ARIA-192-ECB'), + '1.2.410.200046.1.1.7': ('aria-192-cbc', 'ARIA-192-CBC'), + '1.2.410.200046.1.1.8': ('aria-192-cfb', 'ARIA-192-CFB'), + '1.2.410.200046.1.1.9': ('aria-192-ofb', 'ARIA-192-OFB'), + '1.2.410.200046.1.1.10': ('aria-192-ctr', 'ARIA-192-CTR'), + '1.2.410.200046.1.1.11': ('aria-256-ecb', 'ARIA-256-ECB'), + '1.2.410.200046.1.1.12': ('aria-256-cbc', 'ARIA-256-CBC'), + '1.2.410.200046.1.1.13': ('aria-256-cfb', 'ARIA-256-CFB'), + '1.2.410.200046.1.1.14': ('aria-256-ofb', 'ARIA-256-OFB'), + '1.2.410.200046.1.1.15': ('aria-256-ctr', 'ARIA-256-CTR'), + '1.2.410.200046.1.1.34': ('aria-128-gcm', 'ARIA-128-GCM'), + '1.2.410.200046.1.1.35': ('aria-192-gcm', 'ARIA-192-GCM'), + '1.2.410.200046.1.1.36': ('aria-256-gcm', 'ARIA-256-GCM'), + '1.2.410.200046.1.1.37': ('aria-128-ccm', 'ARIA-128-CCM'), + '1.2.410.200046.1.1.38': ('aria-192-ccm', 'ARIA-192-CCM'), + '1.2.410.200046.1.1.39': ('aria-256-ccm', 'ARIA-256-CCM'), + '1.2.643.2.2': ('cryptopro', ), + '1.2.643.2.2.3': ('GOST R 34.11-94 with GOST R 34.10-2001', 'id-GostR3411-94-with-GostR3410-2001'), + '1.2.643.2.2.4': ('GOST R 34.11-94 with GOST R 34.10-94', 'id-GostR3411-94-with-GostR3410-94'), + '1.2.643.2.2.9': ('GOST R 34.11-94', 'md_gost94'), + '1.2.643.2.2.10': ('HMAC GOST 34.11-94', 'id-HMACGostR3411-94'), + '1.2.643.2.2.14.0': ('id-Gost28147-89-None-KeyMeshing', ), + '1.2.643.2.2.14.1': ('id-Gost28147-89-CryptoPro-KeyMeshing', ), + '1.2.643.2.2.19': ('GOST R 34.10-2001', 'gost2001'), + '1.2.643.2.2.20': ('GOST R 34.10-94', 'gost94'), + '1.2.643.2.2.20.1': ('id-GostR3410-94-a', ), + '1.2.643.2.2.20.2': ('id-GostR3410-94-aBis', ), + '1.2.643.2.2.20.3': ('id-GostR3410-94-b', ), + '1.2.643.2.2.20.4': ('id-GostR3410-94-bBis', ), + '1.2.643.2.2.21': ('GOST 28147-89', 'gost89'), + '1.2.643.2.2.22': ('GOST 28147-89 MAC', 'gost-mac'), + '1.2.643.2.2.23': ('GOST R 34.11-94 PRF', 'prf-gostr3411-94'), + '1.2.643.2.2.30.0': ('id-GostR3411-94-TestParamSet', ), + '1.2.643.2.2.30.1': ('id-GostR3411-94-CryptoProParamSet', ), + '1.2.643.2.2.31.0': ('id-Gost28147-89-TestParamSet', ), + '1.2.643.2.2.31.1': ('id-Gost28147-89-CryptoPro-A-ParamSet', ), + '1.2.643.2.2.31.2': ('id-Gost28147-89-CryptoPro-B-ParamSet', ), + '1.2.643.2.2.31.3': ('id-Gost28147-89-CryptoPro-C-ParamSet', ), + '1.2.643.2.2.31.4': ('id-Gost28147-89-CryptoPro-D-ParamSet', ), + '1.2.643.2.2.31.5': ('id-Gost28147-89-CryptoPro-Oscar-1-1-ParamSet', ), + '1.2.643.2.2.31.6': ('id-Gost28147-89-CryptoPro-Oscar-1-0-ParamSet', ), + '1.2.643.2.2.31.7': ('id-Gost28147-89-CryptoPro-RIC-1-ParamSet', ), + '1.2.643.2.2.32.0': ('id-GostR3410-94-TestParamSet', ), + '1.2.643.2.2.32.2': ('id-GostR3410-94-CryptoPro-A-ParamSet', ), + '1.2.643.2.2.32.3': ('id-GostR3410-94-CryptoPro-B-ParamSet', ), + '1.2.643.2.2.32.4': ('id-GostR3410-94-CryptoPro-C-ParamSet', ), + '1.2.643.2.2.32.5': ('id-GostR3410-94-CryptoPro-D-ParamSet', ), + '1.2.643.2.2.33.1': ('id-GostR3410-94-CryptoPro-XchA-ParamSet', ), + '1.2.643.2.2.33.2': ('id-GostR3410-94-CryptoPro-XchB-ParamSet', ), + '1.2.643.2.2.33.3': ('id-GostR3410-94-CryptoPro-XchC-ParamSet', ), + '1.2.643.2.2.35.0': ('id-GostR3410-2001-TestParamSet', ), + '1.2.643.2.2.35.1': ('id-GostR3410-2001-CryptoPro-A-ParamSet', ), + '1.2.643.2.2.35.2': ('id-GostR3410-2001-CryptoPro-B-ParamSet', ), + '1.2.643.2.2.35.3': ('id-GostR3410-2001-CryptoPro-C-ParamSet', ), + '1.2.643.2.2.36.0': ('id-GostR3410-2001-CryptoPro-XchA-ParamSet', ), + '1.2.643.2.2.36.1': ('id-GostR3410-2001-CryptoPro-XchB-ParamSet', ), + '1.2.643.2.2.98': ('GOST R 34.10-2001 DH', 'id-GostR3410-2001DH'), + '1.2.643.2.2.99': ('GOST R 34.10-94 DH', 'id-GostR3410-94DH'), + '1.2.643.2.9': ('cryptocom', ), + '1.2.643.2.9.1.3.3': ('GOST R 34.11-94 with GOST R 34.10-94 Cryptocom', 'id-GostR3411-94-with-GostR3410-94-cc'), + '1.2.643.2.9.1.3.4': ('GOST R 34.11-94 with GOST R 34.10-2001 Cryptocom', 'id-GostR3411-94-with-GostR3410-2001-cc'), + '1.2.643.2.9.1.5.3': ('GOST 34.10-94 Cryptocom', 'gost94cc'), + '1.2.643.2.9.1.5.4': ('GOST 34.10-2001 Cryptocom', 'gost2001cc'), + '1.2.643.2.9.1.6.1': ('GOST 28147-89 Cryptocom ParamSet', 'id-Gost28147-89-cc'), + '1.2.643.2.9.1.8.1': ('GOST R 3410-2001 Parameter Set Cryptocom', 'id-GostR3410-2001-ParamSet-cc'), + '1.2.643.3.131.1.1': ('INN', 'INN'), + '1.2.643.7.1': ('id-tc26', ), + '1.2.643.7.1.1': ('id-tc26-algorithms', ), + '1.2.643.7.1.1.1': ('id-tc26-sign', ), + '1.2.643.7.1.1.1.1': ('GOST R 34.10-2012 with 256 bit modulus', 'gost2012_256'), + '1.2.643.7.1.1.1.2': ('GOST R 34.10-2012 with 512 bit modulus', 'gost2012_512'), + '1.2.643.7.1.1.2': ('id-tc26-digest', ), + '1.2.643.7.1.1.2.2': ('GOST R 34.11-2012 with 256 bit hash', 'md_gost12_256'), + '1.2.643.7.1.1.2.3': ('GOST R 34.11-2012 with 512 bit hash', 'md_gost12_512'), + '1.2.643.7.1.1.3': ('id-tc26-signwithdigest', ), + '1.2.643.7.1.1.3.2': ('GOST R 34.10-2012 with GOST R 34.11-2012 (256 bit)', 'id-tc26-signwithdigest-gost3410-2012-256'), + '1.2.643.7.1.1.3.3': ('GOST R 34.10-2012 with GOST R 34.11-2012 (512 bit)', 'id-tc26-signwithdigest-gost3410-2012-512'), + '1.2.643.7.1.1.4': ('id-tc26-mac', ), + '1.2.643.7.1.1.4.1': ('HMAC GOST 34.11-2012 256 bit', 'id-tc26-hmac-gost-3411-2012-256'), + '1.2.643.7.1.1.4.2': ('HMAC GOST 34.11-2012 512 bit', 'id-tc26-hmac-gost-3411-2012-512'), + '1.2.643.7.1.1.5': ('id-tc26-cipher', ), + '1.2.643.7.1.1.5.1': ('id-tc26-cipher-gostr3412-2015-magma', ), + '1.2.643.7.1.1.5.1.1': ('id-tc26-cipher-gostr3412-2015-magma-ctracpkm', ), + '1.2.643.7.1.1.5.1.2': ('id-tc26-cipher-gostr3412-2015-magma-ctracpkm-omac', ), + '1.2.643.7.1.1.5.2': ('id-tc26-cipher-gostr3412-2015-kuznyechik', ), + '1.2.643.7.1.1.5.2.1': ('id-tc26-cipher-gostr3412-2015-kuznyechik-ctracpkm', ), + '1.2.643.7.1.1.5.2.2': ('id-tc26-cipher-gostr3412-2015-kuznyechik-ctracpkm-omac', ), + '1.2.643.7.1.1.6': ('id-tc26-agreement', ), + '1.2.643.7.1.1.6.1': ('id-tc26-agreement-gost-3410-2012-256', ), + '1.2.643.7.1.1.6.2': ('id-tc26-agreement-gost-3410-2012-512', ), + '1.2.643.7.1.1.7': ('id-tc26-wrap', ), + '1.2.643.7.1.1.7.1': ('id-tc26-wrap-gostr3412-2015-magma', ), + '1.2.643.7.1.1.7.1.1': ('id-tc26-wrap-gostr3412-2015-magma-kexp15', 'id-tc26-wrap-gostr3412-2015-kuznyechik-kexp15'), + '1.2.643.7.1.1.7.2': ('id-tc26-wrap-gostr3412-2015-kuznyechik', ), + '1.2.643.7.1.2': ('id-tc26-constants', ), + '1.2.643.7.1.2.1': ('id-tc26-sign-constants', ), + '1.2.643.7.1.2.1.1': ('id-tc26-gost-3410-2012-256-constants', ), + '1.2.643.7.1.2.1.1.1': ('GOST R 34.10-2012 (256 bit) ParamSet A', 'id-tc26-gost-3410-2012-256-paramSetA'), + '1.2.643.7.1.2.1.1.2': ('GOST R 34.10-2012 (256 bit) ParamSet B', 'id-tc26-gost-3410-2012-256-paramSetB'), + '1.2.643.7.1.2.1.1.3': ('GOST R 34.10-2012 (256 bit) ParamSet C', 'id-tc26-gost-3410-2012-256-paramSetC'), + '1.2.643.7.1.2.1.1.4': ('GOST R 34.10-2012 (256 bit) ParamSet D', 'id-tc26-gost-3410-2012-256-paramSetD'), + '1.2.643.7.1.2.1.2': ('id-tc26-gost-3410-2012-512-constants', ), + '1.2.643.7.1.2.1.2.0': ('GOST R 34.10-2012 (512 bit) testing parameter set', 'id-tc26-gost-3410-2012-512-paramSetTest'), + '1.2.643.7.1.2.1.2.1': ('GOST R 34.10-2012 (512 bit) ParamSet A', 'id-tc26-gost-3410-2012-512-paramSetA'), + '1.2.643.7.1.2.1.2.2': ('GOST R 34.10-2012 (512 bit) ParamSet B', 'id-tc26-gost-3410-2012-512-paramSetB'), + '1.2.643.7.1.2.1.2.3': ('GOST R 34.10-2012 (512 bit) ParamSet C', 'id-tc26-gost-3410-2012-512-paramSetC'), + '1.2.643.7.1.2.2': ('id-tc26-digest-constants', ), + '1.2.643.7.1.2.5': ('id-tc26-cipher-constants', ), + '1.2.643.7.1.2.5.1': ('id-tc26-gost-28147-constants', ), + '1.2.643.7.1.2.5.1.1': ('GOST 28147-89 TC26 parameter set', 'id-tc26-gost-28147-param-Z'), + '1.2.643.100.1': ('OGRN', 'OGRN'), + '1.2.643.100.3': ('SNILS', 'SNILS'), + '1.2.643.100.111': ('Signing Tool of Subject', 'subjectSignTool'), + '1.2.643.100.112': ('Signing Tool of Issuer', 'issuerSignTool'), + '1.2.804': ('ISO-UA', ), + '1.2.804.2.1.1.1': ('ua-pki', ), + '1.2.804.2.1.1.1.1.1.1': ('DSTU Gost 28147-2009', 'dstu28147'), + '1.2.804.2.1.1.1.1.1.1.2': ('DSTU Gost 28147-2009 OFB mode', 'dstu28147-ofb'), + '1.2.804.2.1.1.1.1.1.1.3': ('DSTU Gost 28147-2009 CFB mode', 'dstu28147-cfb'), + '1.2.804.2.1.1.1.1.1.1.5': ('DSTU Gost 28147-2009 key wrap', 'dstu28147-wrap'), + '1.2.804.2.1.1.1.1.1.2': ('HMAC DSTU Gost 34311-95', 'hmacWithDstu34311'), + '1.2.804.2.1.1.1.1.2.1': ('DSTU Gost 34311-95', 'dstu34311'), + '1.2.804.2.1.1.1.1.3.1.1': ('DSTU 4145-2002 little endian', 'dstu4145le'), + '1.2.804.2.1.1.1.1.3.1.1.1.1': ('DSTU 4145-2002 big endian', 'dstu4145be'), + '1.2.804.2.1.1.1.1.3.1.1.2.0': ('DSTU curve 0', 'uacurve0'), + '1.2.804.2.1.1.1.1.3.1.1.2.1': ('DSTU curve 1', 'uacurve1'), + '1.2.804.2.1.1.1.1.3.1.1.2.2': ('DSTU curve 2', 'uacurve2'), + '1.2.804.2.1.1.1.1.3.1.1.2.3': ('DSTU curve 3', 'uacurve3'), + '1.2.804.2.1.1.1.1.3.1.1.2.4': ('DSTU curve 4', 'uacurve4'), + '1.2.804.2.1.1.1.1.3.1.1.2.5': ('DSTU curve 5', 'uacurve5'), + '1.2.804.2.1.1.1.1.3.1.1.2.6': ('DSTU curve 6', 'uacurve6'), + '1.2.804.2.1.1.1.1.3.1.1.2.7': ('DSTU curve 7', 'uacurve7'), + '1.2.804.2.1.1.1.1.3.1.1.2.8': ('DSTU curve 8', 'uacurve8'), + '1.2.804.2.1.1.1.1.3.1.1.2.9': ('DSTU curve 9', 'uacurve9'), + '1.2.840': ('ISO US Member Body', 'ISO-US'), + '1.2.840.10040': ('X9.57', 'X9-57'), + '1.2.840.10040.2': ('holdInstruction', ), + '1.2.840.10040.2.1': ('Hold Instruction None', 'holdInstructionNone'), + '1.2.840.10040.2.2': ('Hold Instruction Call Issuer', 'holdInstructionCallIssuer'), + '1.2.840.10040.2.3': ('Hold Instruction Reject', 'holdInstructionReject'), + '1.2.840.10040.4': ('X9.57 CM ?', 'X9cm'), + '1.2.840.10040.4.1': ('dsaEncryption', 'DSA'), + '1.2.840.10040.4.3': ('dsaWithSHA1', 'DSA-SHA1'), + '1.2.840.10045': ('ANSI X9.62', 'ansi-X9-62'), + '1.2.840.10045.1': ('id-fieldType', ), + '1.2.840.10045.1.1': ('prime-field', ), + '1.2.840.10045.1.2': ('characteristic-two-field', ), + '1.2.840.10045.1.2.3': ('id-characteristic-two-basis', ), + '1.2.840.10045.1.2.3.1': ('onBasis', ), + '1.2.840.10045.1.2.3.2': ('tpBasis', ), + '1.2.840.10045.1.2.3.3': ('ppBasis', ), + '1.2.840.10045.2': ('id-publicKeyType', ), + '1.2.840.10045.2.1': ('id-ecPublicKey', ), + '1.2.840.10045.3': ('ellipticCurve', ), + '1.2.840.10045.3.0': ('c-TwoCurve', ), + '1.2.840.10045.3.0.1': ('c2pnb163v1', ), + '1.2.840.10045.3.0.2': ('c2pnb163v2', ), + '1.2.840.10045.3.0.3': ('c2pnb163v3', ), + '1.2.840.10045.3.0.4': ('c2pnb176v1', ), + '1.2.840.10045.3.0.5': ('c2tnb191v1', ), + '1.2.840.10045.3.0.6': ('c2tnb191v2', ), + '1.2.840.10045.3.0.7': ('c2tnb191v3', ), + '1.2.840.10045.3.0.8': ('c2onb191v4', ), + '1.2.840.10045.3.0.9': ('c2onb191v5', ), + '1.2.840.10045.3.0.10': ('c2pnb208w1', ), + '1.2.840.10045.3.0.11': ('c2tnb239v1', ), + '1.2.840.10045.3.0.12': ('c2tnb239v2', ), + '1.2.840.10045.3.0.13': ('c2tnb239v3', ), + '1.2.840.10045.3.0.14': ('c2onb239v4', ), + '1.2.840.10045.3.0.15': ('c2onb239v5', ), + '1.2.840.10045.3.0.16': ('c2pnb272w1', ), + '1.2.840.10045.3.0.17': ('c2pnb304w1', ), + '1.2.840.10045.3.0.18': ('c2tnb359v1', ), + '1.2.840.10045.3.0.19': ('c2pnb368w1', ), + '1.2.840.10045.3.0.20': ('c2tnb431r1', ), + '1.2.840.10045.3.1': ('primeCurve', ), + '1.2.840.10045.3.1.1': ('prime192v1', ), + '1.2.840.10045.3.1.2': ('prime192v2', ), + '1.2.840.10045.3.1.3': ('prime192v3', ), + '1.2.840.10045.3.1.4': ('prime239v1', ), + '1.2.840.10045.3.1.5': ('prime239v2', ), + '1.2.840.10045.3.1.6': ('prime239v3', ), + '1.2.840.10045.3.1.7': ('prime256v1', ), + '1.2.840.10045.4': ('id-ecSigType', ), + '1.2.840.10045.4.1': ('ecdsa-with-SHA1', ), + '1.2.840.10045.4.2': ('ecdsa-with-Recommended', ), + '1.2.840.10045.4.3': ('ecdsa-with-Specified', ), + '1.2.840.10045.4.3.1': ('ecdsa-with-SHA224', ), + '1.2.840.10045.4.3.2': ('ecdsa-with-SHA256', ), + '1.2.840.10045.4.3.3': ('ecdsa-with-SHA384', ), + '1.2.840.10045.4.3.4': ('ecdsa-with-SHA512', ), + '1.2.840.10046.2.1': ('X9.42 DH', 'dhpublicnumber'), + '1.2.840.113533.7.66.10': ('cast5-cbc', 'CAST5-CBC'), + '1.2.840.113533.7.66.12': ('pbeWithMD5AndCast5CBC', ), + '1.2.840.113533.7.66.13': ('password based MAC', 'id-PasswordBasedMAC'), + '1.2.840.113533.7.66.30': ('Diffie-Hellman based MAC', 'id-DHBasedMac'), + '1.2.840.113549': ('RSA Data Security, Inc.', 'rsadsi'), + '1.2.840.113549.1': ('RSA Data Security, Inc. PKCS', 'pkcs'), + '1.2.840.113549.1.1': ('pkcs1', ), + '1.2.840.113549.1.1.1': ('rsaEncryption', ), + '1.2.840.113549.1.1.2': ('md2WithRSAEncryption', 'RSA-MD2'), + '1.2.840.113549.1.1.3': ('md4WithRSAEncryption', 'RSA-MD4'), + '1.2.840.113549.1.1.4': ('md5WithRSAEncryption', 'RSA-MD5'), + '1.2.840.113549.1.1.5': ('sha1WithRSAEncryption', 'RSA-SHA1'), + '1.2.840.113549.1.1.6': ('rsaOAEPEncryptionSET', ), + '1.2.840.113549.1.1.7': ('rsaesOaep', 'RSAES-OAEP'), + '1.2.840.113549.1.1.8': ('mgf1', 'MGF1'), + '1.2.840.113549.1.1.9': ('pSpecified', 'PSPECIFIED'), + '1.2.840.113549.1.1.10': ('rsassaPss', 'RSASSA-PSS'), + '1.2.840.113549.1.1.11': ('sha256WithRSAEncryption', 'RSA-SHA256'), + '1.2.840.113549.1.1.12': ('sha384WithRSAEncryption', 'RSA-SHA384'), + '1.2.840.113549.1.1.13': ('sha512WithRSAEncryption', 'RSA-SHA512'), + '1.2.840.113549.1.1.14': ('sha224WithRSAEncryption', 'RSA-SHA224'), + '1.2.840.113549.1.1.15': ('sha512-224WithRSAEncryption', 'RSA-SHA512/224'), + '1.2.840.113549.1.1.16': ('sha512-256WithRSAEncryption', 'RSA-SHA512/256'), + '1.2.840.113549.1.3': ('pkcs3', ), + '1.2.840.113549.1.3.1': ('dhKeyAgreement', ), + '1.2.840.113549.1.5': ('pkcs5', ), + '1.2.840.113549.1.5.1': ('pbeWithMD2AndDES-CBC', 'PBE-MD2-DES'), + '1.2.840.113549.1.5.3': ('pbeWithMD5AndDES-CBC', 'PBE-MD5-DES'), + '1.2.840.113549.1.5.4': ('pbeWithMD2AndRC2-CBC', 'PBE-MD2-RC2-64'), + '1.2.840.113549.1.5.6': ('pbeWithMD5AndRC2-CBC', 'PBE-MD5-RC2-64'), + '1.2.840.113549.1.5.10': ('pbeWithSHA1AndDES-CBC', 'PBE-SHA1-DES'), + '1.2.840.113549.1.5.11': ('pbeWithSHA1AndRC2-CBC', 'PBE-SHA1-RC2-64'), + '1.2.840.113549.1.5.12': ('PBKDF2', ), + '1.2.840.113549.1.5.13': ('PBES2', ), + '1.2.840.113549.1.5.14': ('PBMAC1', ), + '1.2.840.113549.1.7': ('pkcs7', ), + '1.2.840.113549.1.7.1': ('pkcs7-data', ), + '1.2.840.113549.1.7.2': ('pkcs7-signedData', ), + '1.2.840.113549.1.7.3': ('pkcs7-envelopedData', ), + '1.2.840.113549.1.7.4': ('pkcs7-signedAndEnvelopedData', ), + '1.2.840.113549.1.7.5': ('pkcs7-digestData', ), + '1.2.840.113549.1.7.6': ('pkcs7-encryptedData', ), + '1.2.840.113549.1.9': ('pkcs9', ), + '1.2.840.113549.1.9.1': ('emailAddress', ), + '1.2.840.113549.1.9.2': ('unstructuredName', ), + '1.2.840.113549.1.9.3': ('contentType', ), + '1.2.840.113549.1.9.4': ('messageDigest', ), + '1.2.840.113549.1.9.5': ('signingTime', ), + '1.2.840.113549.1.9.6': ('countersignature', ), + '1.2.840.113549.1.9.7': ('challengePassword', ), + '1.2.840.113549.1.9.8': ('unstructuredAddress', ), + '1.2.840.113549.1.9.9': ('extendedCertificateAttributes', ), + '1.2.840.113549.1.9.14': ('Extension Request', 'extReq'), + '1.2.840.113549.1.9.15': ('S/MIME Capabilities', 'SMIME-CAPS'), + '1.2.840.113549.1.9.16': ('S/MIME', 'SMIME'), + '1.2.840.113549.1.9.16.0': ('id-smime-mod', ), + '1.2.840.113549.1.9.16.0.1': ('id-smime-mod-cms', ), + '1.2.840.113549.1.9.16.0.2': ('id-smime-mod-ess', ), + '1.2.840.113549.1.9.16.0.3': ('id-smime-mod-oid', ), + '1.2.840.113549.1.9.16.0.4': ('id-smime-mod-msg-v3', ), + '1.2.840.113549.1.9.16.0.5': ('id-smime-mod-ets-eSignature-88', ), + '1.2.840.113549.1.9.16.0.6': ('id-smime-mod-ets-eSignature-97', ), + '1.2.840.113549.1.9.16.0.7': ('id-smime-mod-ets-eSigPolicy-88', ), + '1.2.840.113549.1.9.16.0.8': ('id-smime-mod-ets-eSigPolicy-97', ), + '1.2.840.113549.1.9.16.1': ('id-smime-ct', ), + '1.2.840.113549.1.9.16.1.1': ('id-smime-ct-receipt', ), + '1.2.840.113549.1.9.16.1.2': ('id-smime-ct-authData', ), + '1.2.840.113549.1.9.16.1.3': ('id-smime-ct-publishCert', ), + '1.2.840.113549.1.9.16.1.4': ('id-smime-ct-TSTInfo', ), + '1.2.840.113549.1.9.16.1.5': ('id-smime-ct-TDTInfo', ), + '1.2.840.113549.1.9.16.1.6': ('id-smime-ct-contentInfo', ), + '1.2.840.113549.1.9.16.1.7': ('id-smime-ct-DVCSRequestData', ), + '1.2.840.113549.1.9.16.1.8': ('id-smime-ct-DVCSResponseData', ), + '1.2.840.113549.1.9.16.1.9': ('id-smime-ct-compressedData', ), + '1.2.840.113549.1.9.16.1.19': ('id-smime-ct-contentCollection', ), + '1.2.840.113549.1.9.16.1.23': ('id-smime-ct-authEnvelopedData', ), + '1.2.840.113549.1.9.16.1.27': ('id-ct-asciiTextWithCRLF', ), + '1.2.840.113549.1.9.16.1.28': ('id-ct-xml', ), + '1.2.840.113549.1.9.16.2': ('id-smime-aa', ), + '1.2.840.113549.1.9.16.2.1': ('id-smime-aa-receiptRequest', ), + '1.2.840.113549.1.9.16.2.2': ('id-smime-aa-securityLabel', ), + '1.2.840.113549.1.9.16.2.3': ('id-smime-aa-mlExpandHistory', ), + '1.2.840.113549.1.9.16.2.4': ('id-smime-aa-contentHint', ), + '1.2.840.113549.1.9.16.2.5': ('id-smime-aa-msgSigDigest', ), + '1.2.840.113549.1.9.16.2.6': ('id-smime-aa-encapContentType', ), + '1.2.840.113549.1.9.16.2.7': ('id-smime-aa-contentIdentifier', ), + '1.2.840.113549.1.9.16.2.8': ('id-smime-aa-macValue', ), + '1.2.840.113549.1.9.16.2.9': ('id-smime-aa-equivalentLabels', ), + '1.2.840.113549.1.9.16.2.10': ('id-smime-aa-contentReference', ), + '1.2.840.113549.1.9.16.2.11': ('id-smime-aa-encrypKeyPref', ), + '1.2.840.113549.1.9.16.2.12': ('id-smime-aa-signingCertificate', ), + '1.2.840.113549.1.9.16.2.13': ('id-smime-aa-smimeEncryptCerts', ), + '1.2.840.113549.1.9.16.2.14': ('id-smime-aa-timeStampToken', ), + '1.2.840.113549.1.9.16.2.15': ('id-smime-aa-ets-sigPolicyId', ), + '1.2.840.113549.1.9.16.2.16': ('id-smime-aa-ets-commitmentType', ), + '1.2.840.113549.1.9.16.2.17': ('id-smime-aa-ets-signerLocation', ), + '1.2.840.113549.1.9.16.2.18': ('id-smime-aa-ets-signerAttr', ), + '1.2.840.113549.1.9.16.2.19': ('id-smime-aa-ets-otherSigCert', ), + '1.2.840.113549.1.9.16.2.20': ('id-smime-aa-ets-contentTimestamp', ), + '1.2.840.113549.1.9.16.2.21': ('id-smime-aa-ets-CertificateRefs', ), + '1.2.840.113549.1.9.16.2.22': ('id-smime-aa-ets-RevocationRefs', ), + '1.2.840.113549.1.9.16.2.23': ('id-smime-aa-ets-certValues', ), + '1.2.840.113549.1.9.16.2.24': ('id-smime-aa-ets-revocationValues', ), + '1.2.840.113549.1.9.16.2.25': ('id-smime-aa-ets-escTimeStamp', ), + '1.2.840.113549.1.9.16.2.26': ('id-smime-aa-ets-certCRLTimestamp', ), + '1.2.840.113549.1.9.16.2.27': ('id-smime-aa-ets-archiveTimeStamp', ), + '1.2.840.113549.1.9.16.2.28': ('id-smime-aa-signatureType', ), + '1.2.840.113549.1.9.16.2.29': ('id-smime-aa-dvcs-dvc', ), + '1.2.840.113549.1.9.16.2.47': ('id-smime-aa-signingCertificateV2', ), + '1.2.840.113549.1.9.16.3': ('id-smime-alg', ), + '1.2.840.113549.1.9.16.3.1': ('id-smime-alg-ESDHwith3DES', ), + '1.2.840.113549.1.9.16.3.2': ('id-smime-alg-ESDHwithRC2', ), + '1.2.840.113549.1.9.16.3.3': ('id-smime-alg-3DESwrap', ), + '1.2.840.113549.1.9.16.3.4': ('id-smime-alg-RC2wrap', ), + '1.2.840.113549.1.9.16.3.5': ('id-smime-alg-ESDH', ), + '1.2.840.113549.1.9.16.3.6': ('id-smime-alg-CMS3DESwrap', ), + '1.2.840.113549.1.9.16.3.7': ('id-smime-alg-CMSRC2wrap', ), + '1.2.840.113549.1.9.16.3.8': ('zlib compression', 'ZLIB'), + '1.2.840.113549.1.9.16.3.9': ('id-alg-PWRI-KEK', ), + '1.2.840.113549.1.9.16.4': ('id-smime-cd', ), + '1.2.840.113549.1.9.16.4.1': ('id-smime-cd-ldap', ), + '1.2.840.113549.1.9.16.5': ('id-smime-spq', ), + '1.2.840.113549.1.9.16.5.1': ('id-smime-spq-ets-sqt-uri', ), + '1.2.840.113549.1.9.16.5.2': ('id-smime-spq-ets-sqt-unotice', ), + '1.2.840.113549.1.9.16.6': ('id-smime-cti', ), + '1.2.840.113549.1.9.16.6.1': ('id-smime-cti-ets-proofOfOrigin', ), + '1.2.840.113549.1.9.16.6.2': ('id-smime-cti-ets-proofOfReceipt', ), + '1.2.840.113549.1.9.16.6.3': ('id-smime-cti-ets-proofOfDelivery', ), + '1.2.840.113549.1.9.16.6.4': ('id-smime-cti-ets-proofOfSender', ), + '1.2.840.113549.1.9.16.6.5': ('id-smime-cti-ets-proofOfApproval', ), + '1.2.840.113549.1.9.16.6.6': ('id-smime-cti-ets-proofOfCreation', ), + '1.2.840.113549.1.9.20': ('friendlyName', ), + '1.2.840.113549.1.9.21': ('localKeyID', ), + '1.2.840.113549.1.9.22': ('certTypes', ), + '1.2.840.113549.1.9.22.1': ('x509Certificate', ), + '1.2.840.113549.1.9.22.2': ('sdsiCertificate', ), + '1.2.840.113549.1.9.23': ('crlTypes', ), + '1.2.840.113549.1.9.23.1': ('x509Crl', ), + '1.2.840.113549.1.12': ('pkcs12', ), + '1.2.840.113549.1.12.1': ('pkcs12-pbeids', ), + '1.2.840.113549.1.12.1.1': ('pbeWithSHA1And128BitRC4', 'PBE-SHA1-RC4-128'), + '1.2.840.113549.1.12.1.2': ('pbeWithSHA1And40BitRC4', 'PBE-SHA1-RC4-40'), + '1.2.840.113549.1.12.1.3': ('pbeWithSHA1And3-KeyTripleDES-CBC', 'PBE-SHA1-3DES'), + '1.2.840.113549.1.12.1.4': ('pbeWithSHA1And2-KeyTripleDES-CBC', 'PBE-SHA1-2DES'), + '1.2.840.113549.1.12.1.5': ('pbeWithSHA1And128BitRC2-CBC', 'PBE-SHA1-RC2-128'), + '1.2.840.113549.1.12.1.6': ('pbeWithSHA1And40BitRC2-CBC', 'PBE-SHA1-RC2-40'), + '1.2.840.113549.1.12.10': ('pkcs12-Version1', ), + '1.2.840.113549.1.12.10.1': ('pkcs12-BagIds', ), + '1.2.840.113549.1.12.10.1.1': ('keyBag', ), + '1.2.840.113549.1.12.10.1.2': ('pkcs8ShroudedKeyBag', ), + '1.2.840.113549.1.12.10.1.3': ('certBag', ), + '1.2.840.113549.1.12.10.1.4': ('crlBag', ), + '1.2.840.113549.1.12.10.1.5': ('secretBag', ), + '1.2.840.113549.1.12.10.1.6': ('safeContentsBag', ), + '1.2.840.113549.2.2': ('md2', 'MD2'), + '1.2.840.113549.2.4': ('md4', 'MD4'), + '1.2.840.113549.2.5': ('md5', 'MD5'), + '1.2.840.113549.2.6': ('hmacWithMD5', ), + '1.2.840.113549.2.7': ('hmacWithSHA1', ), + '1.2.840.113549.2.8': ('hmacWithSHA224', ), + '1.2.840.113549.2.9': ('hmacWithSHA256', ), + '1.2.840.113549.2.10': ('hmacWithSHA384', ), + '1.2.840.113549.2.11': ('hmacWithSHA512', ), + '1.2.840.113549.2.12': ('hmacWithSHA512-224', ), + '1.2.840.113549.2.13': ('hmacWithSHA512-256', ), + '1.2.840.113549.3.2': ('rc2-cbc', 'RC2-CBC'), + '1.2.840.113549.3.4': ('rc4', 'RC4'), + '1.2.840.113549.3.7': ('des-ede3-cbc', 'DES-EDE3-CBC'), + '1.2.840.113549.3.8': ('rc5-cbc', 'RC5-CBC'), + '1.2.840.113549.3.10': ('des-cdmf', 'DES-CDMF'), + '1.3': ('identified-organization', 'org', 'ORG'), + '1.3.6': ('dod', 'DOD'), + '1.3.6.1': ('iana', 'IANA', 'internet'), + '1.3.6.1.1': ('Directory', 'directory'), + '1.3.6.1.2': ('Management', 'mgmt'), + '1.3.6.1.3': ('Experimental', 'experimental'), + '1.3.6.1.4': ('Private', 'private'), + '1.3.6.1.4.1': ('Enterprises', 'enterprises'), + '1.3.6.1.4.1.188.7.1.1.2': ('idea-cbc', 'IDEA-CBC'), + '1.3.6.1.4.1.311.2.1.14': ('Microsoft Extension Request', 'msExtReq'), + '1.3.6.1.4.1.311.2.1.21': ('Microsoft Individual Code Signing', 'msCodeInd'), + '1.3.6.1.4.1.311.2.1.22': ('Microsoft Commercial Code Signing', 'msCodeCom'), + '1.3.6.1.4.1.311.10.3.1': ('Microsoft Trust List Signing', 'msCTLSign'), + '1.3.6.1.4.1.311.10.3.3': ('Microsoft Server Gated Crypto', 'msSGC'), + '1.3.6.1.4.1.311.10.3.4': ('Microsoft Encrypted File System', 'msEFS'), + '1.3.6.1.4.1.311.17.1': ('Microsoft CSP Name', 'CSPName'), + '1.3.6.1.4.1.311.17.2': ('Microsoft Local Key set', 'LocalKeySet'), + '1.3.6.1.4.1.311.20.2.2': ('Microsoft Smartcardlogin', 'msSmartcardLogin'), + '1.3.6.1.4.1.311.20.2.3': ('Microsoft Universal Principal Name', 'msUPN'), + '1.3.6.1.4.1.311.60.2.1.1': ('jurisdictionLocalityName', 'jurisdictionL'), + '1.3.6.1.4.1.311.60.2.1.2': ('jurisdictionStateOrProvinceName', 'jurisdictionST'), + '1.3.6.1.4.1.311.60.2.1.3': ('jurisdictionCountryName', 'jurisdictionC'), + '1.3.6.1.4.1.1466.344': ('dcObject', 'dcobject'), + '1.3.6.1.4.1.1722.12.2.1.16': ('blake2b512', 'BLAKE2b512'), + '1.3.6.1.4.1.1722.12.2.2.8': ('blake2s256', 'BLAKE2s256'), + '1.3.6.1.4.1.3029.1.2': ('bf-cbc', 'BF-CBC'), + '1.3.6.1.4.1.11129.2.4.2': ('CT Precertificate SCTs', 'ct_precert_scts'), + '1.3.6.1.4.1.11129.2.4.3': ('CT Precertificate Poison', 'ct_precert_poison'), + '1.3.6.1.4.1.11129.2.4.4': ('CT Precertificate Signer', 'ct_precert_signer'), + '1.3.6.1.4.1.11129.2.4.5': ('CT Certificate SCTs', 'ct_cert_scts'), + '1.3.6.1.4.1.11591.4.11': ('scrypt', 'id-scrypt'), + '1.3.6.1.5': ('Security', 'security'), + '1.3.6.1.5.2.3': ('id-pkinit', ), + '1.3.6.1.5.2.3.4': ('PKINIT Client Auth', 'pkInitClientAuth'), + '1.3.6.1.5.2.3.5': ('Signing KDC Response', 'pkInitKDC'), + '1.3.6.1.5.5.7': ('PKIX', ), + '1.3.6.1.5.5.7.0': ('id-pkix-mod', ), + '1.3.6.1.5.5.7.0.1': ('id-pkix1-explicit-88', ), + '1.3.6.1.5.5.7.0.2': ('id-pkix1-implicit-88', ), + '1.3.6.1.5.5.7.0.3': ('id-pkix1-explicit-93', ), + '1.3.6.1.5.5.7.0.4': ('id-pkix1-implicit-93', ), + '1.3.6.1.5.5.7.0.5': ('id-mod-crmf', ), + '1.3.6.1.5.5.7.0.6': ('id-mod-cmc', ), + '1.3.6.1.5.5.7.0.7': ('id-mod-kea-profile-88', ), + '1.3.6.1.5.5.7.0.8': ('id-mod-kea-profile-93', ), + '1.3.6.1.5.5.7.0.9': ('id-mod-cmp', ), + '1.3.6.1.5.5.7.0.10': ('id-mod-qualified-cert-88', ), + '1.3.6.1.5.5.7.0.11': ('id-mod-qualified-cert-93', ), + '1.3.6.1.5.5.7.0.12': ('id-mod-attribute-cert', ), + '1.3.6.1.5.5.7.0.13': ('id-mod-timestamp-protocol', ), + '1.3.6.1.5.5.7.0.14': ('id-mod-ocsp', ), + '1.3.6.1.5.5.7.0.15': ('id-mod-dvcs', ), + '1.3.6.1.5.5.7.0.16': ('id-mod-cmp2000', ), + '1.3.6.1.5.5.7.1': ('id-pe', ), + '1.3.6.1.5.5.7.1.1': ('Authority Information Access', 'authorityInfoAccess'), + '1.3.6.1.5.5.7.1.2': ('Biometric Info', 'biometricInfo'), + '1.3.6.1.5.5.7.1.3': ('qcStatements', ), + '1.3.6.1.5.5.7.1.4': ('ac-auditEntity', ), + '1.3.6.1.5.5.7.1.5': ('ac-targeting', ), + '1.3.6.1.5.5.7.1.6': ('aaControls', ), + '1.3.6.1.5.5.7.1.7': ('sbgp-ipAddrBlock', ), + '1.3.6.1.5.5.7.1.8': ('sbgp-autonomousSysNum', ), + '1.3.6.1.5.5.7.1.9': ('sbgp-routerIdentifier', ), + '1.3.6.1.5.5.7.1.10': ('ac-proxying', ), + '1.3.6.1.5.5.7.1.11': ('Subject Information Access', 'subjectInfoAccess'), + '1.3.6.1.5.5.7.1.14': ('Proxy Certificate Information', 'proxyCertInfo'), + '1.3.6.1.5.5.7.1.24': ('TLS Feature', 'tlsfeature'), + '1.3.6.1.5.5.7.2': ('id-qt', ), + '1.3.6.1.5.5.7.2.1': ('Policy Qualifier CPS', 'id-qt-cps'), + '1.3.6.1.5.5.7.2.2': ('Policy Qualifier User Notice', 'id-qt-unotice'), + '1.3.6.1.5.5.7.2.3': ('textNotice', ), + '1.3.6.1.5.5.7.3': ('id-kp', ), + '1.3.6.1.5.5.7.3.1': ('TLS Web Server Authentication', 'serverAuth'), + '1.3.6.1.5.5.7.3.2': ('TLS Web Client Authentication', 'clientAuth'), + '1.3.6.1.5.5.7.3.3': ('Code Signing', 'codeSigning'), + '1.3.6.1.5.5.7.3.4': ('E-mail Protection', 'emailProtection'), + '1.3.6.1.5.5.7.3.5': ('IPSec End System', 'ipsecEndSystem'), + '1.3.6.1.5.5.7.3.6': ('IPSec Tunnel', 'ipsecTunnel'), + '1.3.6.1.5.5.7.3.7': ('IPSec User', 'ipsecUser'), + '1.3.6.1.5.5.7.3.8': ('Time Stamping', 'timeStamping'), + '1.3.6.1.5.5.7.3.9': ('OCSP Signing', 'OCSPSigning'), + '1.3.6.1.5.5.7.3.10': ('dvcs', 'DVCS'), + '1.3.6.1.5.5.7.3.17': ('ipsec Internet Key Exchange', 'ipsecIKE'), + '1.3.6.1.5.5.7.3.18': ('Ctrl/provision WAP Access', 'capwapAC'), + '1.3.6.1.5.5.7.3.19': ('Ctrl/Provision WAP Termination', 'capwapWTP'), + '1.3.6.1.5.5.7.3.21': ('SSH Client', 'secureShellClient'), + '1.3.6.1.5.5.7.3.22': ('SSH Server', 'secureShellServer'), + '1.3.6.1.5.5.7.3.23': ('Send Router', 'sendRouter'), + '1.3.6.1.5.5.7.3.24': ('Send Proxied Router', 'sendProxiedRouter'), + '1.3.6.1.5.5.7.3.25': ('Send Owner', 'sendOwner'), + '1.3.6.1.5.5.7.3.26': ('Send Proxied Owner', 'sendProxiedOwner'), + '1.3.6.1.5.5.7.3.27': ('CMC Certificate Authority', 'cmcCA'), + '1.3.6.1.5.5.7.3.28': ('CMC Registration Authority', 'cmcRA'), + '1.3.6.1.5.5.7.4': ('id-it', ), + '1.3.6.1.5.5.7.4.1': ('id-it-caProtEncCert', ), + '1.3.6.1.5.5.7.4.2': ('id-it-signKeyPairTypes', ), + '1.3.6.1.5.5.7.4.3': ('id-it-encKeyPairTypes', ), + '1.3.6.1.5.5.7.4.4': ('id-it-preferredSymmAlg', ), + '1.3.6.1.5.5.7.4.5': ('id-it-caKeyUpdateInfo', ), + '1.3.6.1.5.5.7.4.6': ('id-it-currentCRL', ), + '1.3.6.1.5.5.7.4.7': ('id-it-unsupportedOIDs', ), + '1.3.6.1.5.5.7.4.8': ('id-it-subscriptionRequest', ), + '1.3.6.1.5.5.7.4.9': ('id-it-subscriptionResponse', ), + '1.3.6.1.5.5.7.4.10': ('id-it-keyPairParamReq', ), + '1.3.6.1.5.5.7.4.11': ('id-it-keyPairParamRep', ), + '1.3.6.1.5.5.7.4.12': ('id-it-revPassphrase', ), + '1.3.6.1.5.5.7.4.13': ('id-it-implicitConfirm', ), + '1.3.6.1.5.5.7.4.14': ('id-it-confirmWaitTime', ), + '1.3.6.1.5.5.7.4.15': ('id-it-origPKIMessage', ), + '1.3.6.1.5.5.7.4.16': ('id-it-suppLangTags', ), + '1.3.6.1.5.5.7.5': ('id-pkip', ), + '1.3.6.1.5.5.7.5.1': ('id-regCtrl', ), + '1.3.6.1.5.5.7.5.1.1': ('id-regCtrl-regToken', ), + '1.3.6.1.5.5.7.5.1.2': ('id-regCtrl-authenticator', ), + '1.3.6.1.5.5.7.5.1.3': ('id-regCtrl-pkiPublicationInfo', ), + '1.3.6.1.5.5.7.5.1.4': ('id-regCtrl-pkiArchiveOptions', ), + '1.3.6.1.5.5.7.5.1.5': ('id-regCtrl-oldCertID', ), + '1.3.6.1.5.5.7.5.1.6': ('id-regCtrl-protocolEncrKey', ), + '1.3.6.1.5.5.7.5.2': ('id-regInfo', ), + '1.3.6.1.5.5.7.5.2.1': ('id-regInfo-utf8Pairs', ), + '1.3.6.1.5.5.7.5.2.2': ('id-regInfo-certReq', ), + '1.3.6.1.5.5.7.6': ('id-alg', ), + '1.3.6.1.5.5.7.6.1': ('id-alg-des40', ), + '1.3.6.1.5.5.7.6.2': ('id-alg-noSignature', ), + '1.3.6.1.5.5.7.6.3': ('id-alg-dh-sig-hmac-sha1', ), + '1.3.6.1.5.5.7.6.4': ('id-alg-dh-pop', ), + '1.3.6.1.5.5.7.7': ('id-cmc', ), + '1.3.6.1.5.5.7.7.1': ('id-cmc-statusInfo', ), + '1.3.6.1.5.5.7.7.2': ('id-cmc-identification', ), + '1.3.6.1.5.5.7.7.3': ('id-cmc-identityProof', ), + '1.3.6.1.5.5.7.7.4': ('id-cmc-dataReturn', ), + '1.3.6.1.5.5.7.7.5': ('id-cmc-transactionId', ), + '1.3.6.1.5.5.7.7.6': ('id-cmc-senderNonce', ), + '1.3.6.1.5.5.7.7.7': ('id-cmc-recipientNonce', ), + '1.3.6.1.5.5.7.7.8': ('id-cmc-addExtensions', ), + '1.3.6.1.5.5.7.7.9': ('id-cmc-encryptedPOP', ), + '1.3.6.1.5.5.7.7.10': ('id-cmc-decryptedPOP', ), + '1.3.6.1.5.5.7.7.11': ('id-cmc-lraPOPWitness', ), + '1.3.6.1.5.5.7.7.15': ('id-cmc-getCert', ), + '1.3.6.1.5.5.7.7.16': ('id-cmc-getCRL', ), + '1.3.6.1.5.5.7.7.17': ('id-cmc-revokeRequest', ), + '1.3.6.1.5.5.7.7.18': ('id-cmc-regInfo', ), + '1.3.6.1.5.5.7.7.19': ('id-cmc-responseInfo', ), + '1.3.6.1.5.5.7.7.21': ('id-cmc-queryPending', ), + '1.3.6.1.5.5.7.7.22': ('id-cmc-popLinkRandom', ), + '1.3.6.1.5.5.7.7.23': ('id-cmc-popLinkWitness', ), + '1.3.6.1.5.5.7.7.24': ('id-cmc-confirmCertAcceptance', ), + '1.3.6.1.5.5.7.8': ('id-on', ), + '1.3.6.1.5.5.7.8.1': ('id-on-personalData', ), + '1.3.6.1.5.5.7.8.3': ('Permanent Identifier', 'id-on-permanentIdentifier'), + '1.3.6.1.5.5.7.9': ('id-pda', ), + '1.3.6.1.5.5.7.9.1': ('id-pda-dateOfBirth', ), + '1.3.6.1.5.5.7.9.2': ('id-pda-placeOfBirth', ), + '1.3.6.1.5.5.7.9.3': ('id-pda-gender', ), + '1.3.6.1.5.5.7.9.4': ('id-pda-countryOfCitizenship', ), + '1.3.6.1.5.5.7.9.5': ('id-pda-countryOfResidence', ), + '1.3.6.1.5.5.7.10': ('id-aca', ), + '1.3.6.1.5.5.7.10.1': ('id-aca-authenticationInfo', ), + '1.3.6.1.5.5.7.10.2': ('id-aca-accessIdentity', ), + '1.3.6.1.5.5.7.10.3': ('id-aca-chargingIdentity', ), + '1.3.6.1.5.5.7.10.4': ('id-aca-group', ), + '1.3.6.1.5.5.7.10.5': ('id-aca-role', ), + '1.3.6.1.5.5.7.10.6': ('id-aca-encAttrs', ), + '1.3.6.1.5.5.7.11': ('id-qcs', ), + '1.3.6.1.5.5.7.11.1': ('id-qcs-pkixQCSyntax-v1', ), + '1.3.6.1.5.5.7.12': ('id-cct', ), + '1.3.6.1.5.5.7.12.1': ('id-cct-crs', ), + '1.3.6.1.5.5.7.12.2': ('id-cct-PKIData', ), + '1.3.6.1.5.5.7.12.3': ('id-cct-PKIResponse', ), + '1.3.6.1.5.5.7.21': ('id-ppl', ), + '1.3.6.1.5.5.7.21.0': ('Any language', 'id-ppl-anyLanguage'), + '1.3.6.1.5.5.7.21.1': ('Inherit all', 'id-ppl-inheritAll'), + '1.3.6.1.5.5.7.21.2': ('Independent', 'id-ppl-independent'), + '1.3.6.1.5.5.7.48': ('id-ad', ), + '1.3.6.1.5.5.7.48.1': ('OCSP', 'OCSP', 'id-pkix-OCSP'), + '1.3.6.1.5.5.7.48.1.1': ('Basic OCSP Response', 'basicOCSPResponse'), + '1.3.6.1.5.5.7.48.1.2': ('OCSP Nonce', 'Nonce'), + '1.3.6.1.5.5.7.48.1.3': ('OCSP CRL ID', 'CrlID'), + '1.3.6.1.5.5.7.48.1.4': ('Acceptable OCSP Responses', 'acceptableResponses'), + '1.3.6.1.5.5.7.48.1.5': ('OCSP No Check', 'noCheck'), + '1.3.6.1.5.5.7.48.1.6': ('OCSP Archive Cutoff', 'archiveCutoff'), + '1.3.6.1.5.5.7.48.1.7': ('OCSP Service Locator', 'serviceLocator'), + '1.3.6.1.5.5.7.48.1.8': ('Extended OCSP Status', 'extendedStatus'), + '1.3.6.1.5.5.7.48.1.9': ('valid', ), + '1.3.6.1.5.5.7.48.1.10': ('path', ), + '1.3.6.1.5.5.7.48.1.11': ('Trust Root', 'trustRoot'), + '1.3.6.1.5.5.7.48.2': ('CA Issuers', 'caIssuers'), + '1.3.6.1.5.5.7.48.3': ('AD Time Stamping', 'ad_timestamping'), + '1.3.6.1.5.5.7.48.4': ('ad dvcs', 'AD_DVCS'), + '1.3.6.1.5.5.7.48.5': ('CA Repository', 'caRepository'), + '1.3.6.1.5.5.8.1.1': ('hmac-md5', 'HMAC-MD5'), + '1.3.6.1.5.5.8.1.2': ('hmac-sha1', 'HMAC-SHA1'), + '1.3.6.1.6': ('SNMPv2', 'snmpv2'), + '1.3.6.1.7': ('Mail', ), + '1.3.6.1.7.1': ('MIME MHS', 'mime-mhs'), + '1.3.6.1.7.1.1': ('mime-mhs-headings', 'mime-mhs-headings'), + '1.3.6.1.7.1.1.1': ('id-hex-partial-message', 'id-hex-partial-message'), + '1.3.6.1.7.1.1.2': ('id-hex-multipart-message', 'id-hex-multipart-message'), + '1.3.6.1.7.1.2': ('mime-mhs-bodies', 'mime-mhs-bodies'), + '1.3.14.3.2': ('algorithm', 'algorithm'), + '1.3.14.3.2.3': ('md5WithRSA', 'RSA-NP-MD5'), + '1.3.14.3.2.6': ('des-ecb', 'DES-ECB'), + '1.3.14.3.2.7': ('des-cbc', 'DES-CBC'), + '1.3.14.3.2.8': ('des-ofb', 'DES-OFB'), + '1.3.14.3.2.9': ('des-cfb', 'DES-CFB'), + '1.3.14.3.2.11': ('rsaSignature', ), + '1.3.14.3.2.12': ('dsaEncryption-old', 'DSA-old'), + '1.3.14.3.2.13': ('dsaWithSHA', 'DSA-SHA'), + '1.3.14.3.2.15': ('shaWithRSAEncryption', 'RSA-SHA'), + '1.3.14.3.2.17': ('des-ede', 'DES-EDE'), + '1.3.14.3.2.18': ('sha', 'SHA'), + '1.3.14.3.2.26': ('sha1', 'SHA1'), + '1.3.14.3.2.27': ('dsaWithSHA1-old', 'DSA-SHA1-old'), + '1.3.14.3.2.29': ('sha1WithRSA', 'RSA-SHA1-2'), + '1.3.36.3.2.1': ('ripemd160', 'RIPEMD160'), + '1.3.36.3.3.1.2': ('ripemd160WithRSA', 'RSA-RIPEMD160'), + '1.3.36.3.3.2.8.1.1.1': ('brainpoolP160r1', ), + '1.3.36.3.3.2.8.1.1.2': ('brainpoolP160t1', ), + '1.3.36.3.3.2.8.1.1.3': ('brainpoolP192r1', ), + '1.3.36.3.3.2.8.1.1.4': ('brainpoolP192t1', ), + '1.3.36.3.3.2.8.1.1.5': ('brainpoolP224r1', ), + '1.3.36.3.3.2.8.1.1.6': ('brainpoolP224t1', ), + '1.3.36.3.3.2.8.1.1.7': ('brainpoolP256r1', ), + '1.3.36.3.3.2.8.1.1.8': ('brainpoolP256t1', ), + '1.3.36.3.3.2.8.1.1.9': ('brainpoolP320r1', ), + '1.3.36.3.3.2.8.1.1.10': ('brainpoolP320t1', ), + '1.3.36.3.3.2.8.1.1.11': ('brainpoolP384r1', ), + '1.3.36.3.3.2.8.1.1.12': ('brainpoolP384t1', ), + '1.3.36.3.3.2.8.1.1.13': ('brainpoolP512r1', ), + '1.3.36.3.3.2.8.1.1.14': ('brainpoolP512t1', ), + '1.3.36.8.3.3': ('Professional Information or basis for Admission', 'x509ExtAdmission'), + '1.3.101.1.4.1': ('Strong Extranet ID', 'SXNetID'), + '1.3.101.110': ('X25519', ), + '1.3.101.111': ('X448', ), + '1.3.101.112': ('ED25519', ), + '1.3.101.113': ('ED448', ), + '1.3.111': ('ieee', ), + '1.3.111.2.1619': ('IEEE Security in Storage Working Group', 'ieee-siswg'), + '1.3.111.2.1619.0.1.1': ('aes-128-xts', 'AES-128-XTS'), + '1.3.111.2.1619.0.1.2': ('aes-256-xts', 'AES-256-XTS'), + '1.3.132': ('certicom-arc', ), + '1.3.132.0': ('secg_ellipticCurve', ), + '1.3.132.0.1': ('sect163k1', ), + '1.3.132.0.2': ('sect163r1', ), + '1.3.132.0.3': ('sect239k1', ), + '1.3.132.0.4': ('sect113r1', ), + '1.3.132.0.5': ('sect113r2', ), + '1.3.132.0.6': ('secp112r1', ), + '1.3.132.0.7': ('secp112r2', ), + '1.3.132.0.8': ('secp160r1', ), + '1.3.132.0.9': ('secp160k1', ), + '1.3.132.0.10': ('secp256k1', ), + '1.3.132.0.15': ('sect163r2', ), + '1.3.132.0.16': ('sect283k1', ), + '1.3.132.0.17': ('sect283r1', ), + '1.3.132.0.22': ('sect131r1', ), + '1.3.132.0.23': ('sect131r2', ), + '1.3.132.0.24': ('sect193r1', ), + '1.3.132.0.25': ('sect193r2', ), + '1.3.132.0.26': ('sect233k1', ), + '1.3.132.0.27': ('sect233r1', ), + '1.3.132.0.28': ('secp128r1', ), + '1.3.132.0.29': ('secp128r2', ), + '1.3.132.0.30': ('secp160r2', ), + '1.3.132.0.31': ('secp192k1', ), + '1.3.132.0.32': ('secp224k1', ), + '1.3.132.0.33': ('secp224r1', ), + '1.3.132.0.34': ('secp384r1', ), + '1.3.132.0.35': ('secp521r1', ), + '1.3.132.0.36': ('sect409k1', ), + '1.3.132.0.37': ('sect409r1', ), + '1.3.132.0.38': ('sect571k1', ), + '1.3.132.0.39': ('sect571r1', ), + '1.3.132.1': ('secg-scheme', ), + '1.3.132.1.11.0': ('dhSinglePass-stdDH-sha224kdf-scheme', ), + '1.3.132.1.11.1': ('dhSinglePass-stdDH-sha256kdf-scheme', ), + '1.3.132.1.11.2': ('dhSinglePass-stdDH-sha384kdf-scheme', ), + '1.3.132.1.11.3': ('dhSinglePass-stdDH-sha512kdf-scheme', ), + '1.3.132.1.14.0': ('dhSinglePass-cofactorDH-sha224kdf-scheme', ), + '1.3.132.1.14.1': ('dhSinglePass-cofactorDH-sha256kdf-scheme', ), + '1.3.132.1.14.2': ('dhSinglePass-cofactorDH-sha384kdf-scheme', ), + '1.3.132.1.14.3': ('dhSinglePass-cofactorDH-sha512kdf-scheme', ), + '1.3.133.16.840.63.0': ('x9-63-scheme', ), + '1.3.133.16.840.63.0.2': ('dhSinglePass-stdDH-sha1kdf-scheme', ), + '1.3.133.16.840.63.0.3': ('dhSinglePass-cofactorDH-sha1kdf-scheme', ), + '2': ('joint-iso-itu-t', 'JOINT-ISO-ITU-T', 'joint-iso-ccitt'), + '2.5': ('directory services (X.500)', 'X500'), + '2.5.1.5': ('Selected Attribute Types', 'selected-attribute-types'), + '2.5.1.5.55': ('clearance', ), + '2.5.4': ('X509', ), + '2.5.4.3': ('commonName', 'CN'), + '2.5.4.4': ('surname', 'SN'), + '2.5.4.5': ('serialNumber', ), + '2.5.4.6': ('countryName', 'C'), + '2.5.4.7': ('localityName', 'L'), + '2.5.4.8': ('stateOrProvinceName', 'ST'), + '2.5.4.9': ('streetAddress', 'street'), + '2.5.4.10': ('organizationName', 'O'), + '2.5.4.11': ('organizationalUnitName', 'OU'), + '2.5.4.12': ('title', 'title'), + '2.5.4.13': ('description', ), + '2.5.4.14': ('searchGuide', ), + '2.5.4.15': ('businessCategory', ), + '2.5.4.16': ('postalAddress', ), + '2.5.4.17': ('postalCode', ), + '2.5.4.18': ('postOfficeBox', ), + '2.5.4.19': ('physicalDeliveryOfficeName', ), + '2.5.4.20': ('telephoneNumber', ), + '2.5.4.21': ('telexNumber', ), + '2.5.4.22': ('teletexTerminalIdentifier', ), + '2.5.4.23': ('facsimileTelephoneNumber', ), + '2.5.4.24': ('x121Address', ), + '2.5.4.25': ('internationaliSDNNumber', ), + '2.5.4.26': ('registeredAddress', ), + '2.5.4.27': ('destinationIndicator', ), + '2.5.4.28': ('preferredDeliveryMethod', ), + '2.5.4.29': ('presentationAddress', ), + '2.5.4.30': ('supportedApplicationContext', ), + '2.5.4.31': ('member', ), + '2.5.4.32': ('owner', ), + '2.5.4.33': ('roleOccupant', ), + '2.5.4.34': ('seeAlso', ), + '2.5.4.35': ('userPassword', ), + '2.5.4.36': ('userCertificate', ), + '2.5.4.37': ('cACertificate', ), + '2.5.4.38': ('authorityRevocationList', ), + '2.5.4.39': ('certificateRevocationList', ), + '2.5.4.40': ('crossCertificatePair', ), + '2.5.4.41': ('name', 'name'), + '2.5.4.42': ('givenName', 'GN'), + '2.5.4.43': ('initials', 'initials'), + '2.5.4.44': ('generationQualifier', ), + '2.5.4.45': ('x500UniqueIdentifier', ), + '2.5.4.46': ('dnQualifier', 'dnQualifier'), + '2.5.4.47': ('enhancedSearchGuide', ), + '2.5.4.48': ('protocolInformation', ), + '2.5.4.49': ('distinguishedName', ), + '2.5.4.50': ('uniqueMember', ), + '2.5.4.51': ('houseIdentifier', ), + '2.5.4.52': ('supportedAlgorithms', ), + '2.5.4.53': ('deltaRevocationList', ), + '2.5.4.54': ('dmdName', ), + '2.5.4.65': ('pseudonym', ), + '2.5.4.72': ('role', 'role'), + '2.5.4.97': ('organizationIdentifier', ), + '2.5.4.98': ('countryCode3c', 'c3'), + '2.5.4.99': ('countryCode3n', 'n3'), + '2.5.4.100': ('dnsName', ), + '2.5.8': ('directory services - algorithms', 'X500algorithms'), + '2.5.8.1.1': ('rsa', 'RSA'), + '2.5.8.3.100': ('mdc2WithRSA', 'RSA-MDC2'), + '2.5.8.3.101': ('mdc2', 'MDC2'), + '2.5.29': ('id-ce', ), + '2.5.29.9': ('X509v3 Subject Directory Attributes', 'subjectDirectoryAttributes'), + '2.5.29.14': ('X509v3 Subject Key Identifier', 'subjectKeyIdentifier'), + '2.5.29.15': ('X509v3 Key Usage', 'keyUsage'), + '2.5.29.16': ('X509v3 Private Key Usage Period', 'privateKeyUsagePeriod'), + '2.5.29.17': ('X509v3 Subject Alternative Name', 'subjectAltName'), + '2.5.29.18': ('X509v3 Issuer Alternative Name', 'issuerAltName'), + '2.5.29.19': ('X509v3 Basic Constraints', 'basicConstraints'), + '2.5.29.20': ('X509v3 CRL Number', 'crlNumber'), + '2.5.29.21': ('X509v3 CRL Reason Code', 'CRLReason'), + '2.5.29.23': ('Hold Instruction Code', 'holdInstructionCode'), + '2.5.29.24': ('Invalidity Date', 'invalidityDate'), + '2.5.29.27': ('X509v3 Delta CRL Indicator', 'deltaCRL'), + '2.5.29.28': ('X509v3 Issuing Distribution Point', 'issuingDistributionPoint'), + '2.5.29.29': ('X509v3 Certificate Issuer', 'certificateIssuer'), + '2.5.29.30': ('X509v3 Name Constraints', 'nameConstraints'), + '2.5.29.31': ('X509v3 CRL Distribution Points', 'crlDistributionPoints'), + '2.5.29.32': ('X509v3 Certificate Policies', 'certificatePolicies'), + '2.5.29.32.0': ('X509v3 Any Policy', 'anyPolicy'), + '2.5.29.33': ('X509v3 Policy Mappings', 'policyMappings'), + '2.5.29.35': ('X509v3 Authority Key Identifier', 'authorityKeyIdentifier'), + '2.5.29.36': ('X509v3 Policy Constraints', 'policyConstraints'), + '2.5.29.37': ('X509v3 Extended Key Usage', 'extendedKeyUsage'), + '2.5.29.37.0': ('Any Extended Key Usage', 'anyExtendedKeyUsage'), + '2.5.29.46': ('X509v3 Freshest CRL', 'freshestCRL'), + '2.5.29.54': ('X509v3 Inhibit Any Policy', 'inhibitAnyPolicy'), + '2.5.29.55': ('X509v3 AC Targeting', 'targetInformation'), + '2.5.29.56': ('X509v3 No Revocation Available', 'noRevAvail'), + '2.16.840.1.101.3': ('csor', ), + '2.16.840.1.101.3.4': ('nistAlgorithms', ), + '2.16.840.1.101.3.4.1': ('aes', ), + '2.16.840.1.101.3.4.1.1': ('aes-128-ecb', 'AES-128-ECB'), + '2.16.840.1.101.3.4.1.2': ('aes-128-cbc', 'AES-128-CBC'), + '2.16.840.1.101.3.4.1.3': ('aes-128-ofb', 'AES-128-OFB'), + '2.16.840.1.101.3.4.1.4': ('aes-128-cfb', 'AES-128-CFB'), + '2.16.840.1.101.3.4.1.5': ('id-aes128-wrap', ), + '2.16.840.1.101.3.4.1.6': ('aes-128-gcm', 'id-aes128-GCM'), + '2.16.840.1.101.3.4.1.7': ('aes-128-ccm', 'id-aes128-CCM'), + '2.16.840.1.101.3.4.1.8': ('id-aes128-wrap-pad', ), + '2.16.840.1.101.3.4.1.21': ('aes-192-ecb', 'AES-192-ECB'), + '2.16.840.1.101.3.4.1.22': ('aes-192-cbc', 'AES-192-CBC'), + '2.16.840.1.101.3.4.1.23': ('aes-192-ofb', 'AES-192-OFB'), + '2.16.840.1.101.3.4.1.24': ('aes-192-cfb', 'AES-192-CFB'), + '2.16.840.1.101.3.4.1.25': ('id-aes192-wrap', ), + '2.16.840.1.101.3.4.1.26': ('aes-192-gcm', 'id-aes192-GCM'), + '2.16.840.1.101.3.4.1.27': ('aes-192-ccm', 'id-aes192-CCM'), + '2.16.840.1.101.3.4.1.28': ('id-aes192-wrap-pad', ), + '2.16.840.1.101.3.4.1.41': ('aes-256-ecb', 'AES-256-ECB'), + '2.16.840.1.101.3.4.1.42': ('aes-256-cbc', 'AES-256-CBC'), + '2.16.840.1.101.3.4.1.43': ('aes-256-ofb', 'AES-256-OFB'), + '2.16.840.1.101.3.4.1.44': ('aes-256-cfb', 'AES-256-CFB'), + '2.16.840.1.101.3.4.1.45': ('id-aes256-wrap', ), + '2.16.840.1.101.3.4.1.46': ('aes-256-gcm', 'id-aes256-GCM'), + '2.16.840.1.101.3.4.1.47': ('aes-256-ccm', 'id-aes256-CCM'), + '2.16.840.1.101.3.4.1.48': ('id-aes256-wrap-pad', ), + '2.16.840.1.101.3.4.2': ('nist_hashalgs', ), + '2.16.840.1.101.3.4.2.1': ('sha256', 'SHA256'), + '2.16.840.1.101.3.4.2.2': ('sha384', 'SHA384'), + '2.16.840.1.101.3.4.2.3': ('sha512', 'SHA512'), + '2.16.840.1.101.3.4.2.4': ('sha224', 'SHA224'), + '2.16.840.1.101.3.4.2.5': ('sha512-224', 'SHA512-224'), + '2.16.840.1.101.3.4.2.6': ('sha512-256', 'SHA512-256'), + '2.16.840.1.101.3.4.2.7': ('sha3-224', 'SHA3-224'), + '2.16.840.1.101.3.4.2.8': ('sha3-256', 'SHA3-256'), + '2.16.840.1.101.3.4.2.9': ('sha3-384', 'SHA3-384'), + '2.16.840.1.101.3.4.2.10': ('sha3-512', 'SHA3-512'), + '2.16.840.1.101.3.4.2.11': ('shake128', 'SHAKE128'), + '2.16.840.1.101.3.4.2.12': ('shake256', 'SHAKE256'), + '2.16.840.1.101.3.4.2.13': ('hmac-sha3-224', 'id-hmacWithSHA3-224'), + '2.16.840.1.101.3.4.2.14': ('hmac-sha3-256', 'id-hmacWithSHA3-256'), + '2.16.840.1.101.3.4.2.15': ('hmac-sha3-384', 'id-hmacWithSHA3-384'), + '2.16.840.1.101.3.4.2.16': ('hmac-sha3-512', 'id-hmacWithSHA3-512'), + '2.16.840.1.101.3.4.3': ('dsa_with_sha2', 'sigAlgs'), + '2.16.840.1.101.3.4.3.1': ('dsa_with_SHA224', ), + '2.16.840.1.101.3.4.3.2': ('dsa_with_SHA256', ), + '2.16.840.1.101.3.4.3.3': ('dsa_with_SHA384', 'id-dsa-with-sha384'), + '2.16.840.1.101.3.4.3.4': ('dsa_with_SHA512', 'id-dsa-with-sha512'), + '2.16.840.1.101.3.4.3.5': ('dsa_with_SHA3-224', 'id-dsa-with-sha3-224'), + '2.16.840.1.101.3.4.3.6': ('dsa_with_SHA3-256', 'id-dsa-with-sha3-256'), + '2.16.840.1.101.3.4.3.7': ('dsa_with_SHA3-384', 'id-dsa-with-sha3-384'), + '2.16.840.1.101.3.4.3.8': ('dsa_with_SHA3-512', 'id-dsa-with-sha3-512'), + '2.16.840.1.101.3.4.3.9': ('ecdsa_with_SHA3-224', 'id-ecdsa-with-sha3-224'), + '2.16.840.1.101.3.4.3.10': ('ecdsa_with_SHA3-256', 'id-ecdsa-with-sha3-256'), + '2.16.840.1.101.3.4.3.11': ('ecdsa_with_SHA3-384', 'id-ecdsa-with-sha3-384'), + '2.16.840.1.101.3.4.3.12': ('ecdsa_with_SHA3-512', 'id-ecdsa-with-sha3-512'), + '2.16.840.1.101.3.4.3.13': ('RSA-SHA3-224', 'id-rsassa-pkcs1-v1_5-with-sha3-224'), + '2.16.840.1.101.3.4.3.14': ('RSA-SHA3-256', 'id-rsassa-pkcs1-v1_5-with-sha3-256'), + '2.16.840.1.101.3.4.3.15': ('RSA-SHA3-384', 'id-rsassa-pkcs1-v1_5-with-sha3-384'), + '2.16.840.1.101.3.4.3.16': ('RSA-SHA3-512', 'id-rsassa-pkcs1-v1_5-with-sha3-512'), + '2.16.840.1.113730': ('Netscape Communications Corp.', 'Netscape'), + '2.16.840.1.113730.1': ('Netscape Certificate Extension', 'nsCertExt'), + '2.16.840.1.113730.1.1': ('Netscape Cert Type', 'nsCertType'), + '2.16.840.1.113730.1.2': ('Netscape Base Url', 'nsBaseUrl'), + '2.16.840.1.113730.1.3': ('Netscape Revocation Url', 'nsRevocationUrl'), + '2.16.840.1.113730.1.4': ('Netscape CA Revocation Url', 'nsCaRevocationUrl'), + '2.16.840.1.113730.1.7': ('Netscape Renewal Url', 'nsRenewalUrl'), + '2.16.840.1.113730.1.8': ('Netscape CA Policy Url', 'nsCaPolicyUrl'), + '2.16.840.1.113730.1.12': ('Netscape SSL Server Name', 'nsSslServerName'), + '2.16.840.1.113730.1.13': ('Netscape Comment', 'nsComment'), + '2.16.840.1.113730.2': ('Netscape Data Type', 'nsDataType'), + '2.16.840.1.113730.2.5': ('Netscape Certificate Sequence', 'nsCertSequence'), + '2.16.840.1.113730.4.1': ('Netscape Server Gated Crypto', 'nsSGC'), + '2.23': ('International Organizations', 'international-organizations'), + '2.23.42': ('Secure Electronic Transactions', 'id-set'), + '2.23.42.0': ('content types', 'set-ctype'), + '2.23.42.0.0': ('setct-PANData', ), + '2.23.42.0.1': ('setct-PANToken', ), + '2.23.42.0.2': ('setct-PANOnly', ), + '2.23.42.0.3': ('setct-OIData', ), + '2.23.42.0.4': ('setct-PI', ), + '2.23.42.0.5': ('setct-PIData', ), + '2.23.42.0.6': ('setct-PIDataUnsigned', ), + '2.23.42.0.7': ('setct-HODInput', ), + '2.23.42.0.8': ('setct-AuthResBaggage', ), + '2.23.42.0.9': ('setct-AuthRevReqBaggage', ), + '2.23.42.0.10': ('setct-AuthRevResBaggage', ), + '2.23.42.0.11': ('setct-CapTokenSeq', ), + '2.23.42.0.12': ('setct-PInitResData', ), + '2.23.42.0.13': ('setct-PI-TBS', ), + '2.23.42.0.14': ('setct-PResData', ), + '2.23.42.0.16': ('setct-AuthReqTBS', ), + '2.23.42.0.17': ('setct-AuthResTBS', ), + '2.23.42.0.18': ('setct-AuthResTBSX', ), + '2.23.42.0.19': ('setct-AuthTokenTBS', ), + '2.23.42.0.20': ('setct-CapTokenData', ), + '2.23.42.0.21': ('setct-CapTokenTBS', ), + '2.23.42.0.22': ('setct-AcqCardCodeMsg', ), + '2.23.42.0.23': ('setct-AuthRevReqTBS', ), + '2.23.42.0.24': ('setct-AuthRevResData', ), + '2.23.42.0.25': ('setct-AuthRevResTBS', ), + '2.23.42.0.26': ('setct-CapReqTBS', ), + '2.23.42.0.27': ('setct-CapReqTBSX', ), + '2.23.42.0.28': ('setct-CapResData', ), + '2.23.42.0.29': ('setct-CapRevReqTBS', ), + '2.23.42.0.30': ('setct-CapRevReqTBSX', ), + '2.23.42.0.31': ('setct-CapRevResData', ), + '2.23.42.0.32': ('setct-CredReqTBS', ), + '2.23.42.0.33': ('setct-CredReqTBSX', ), + '2.23.42.0.34': ('setct-CredResData', ), + '2.23.42.0.35': ('setct-CredRevReqTBS', ), + '2.23.42.0.36': ('setct-CredRevReqTBSX', ), + '2.23.42.0.37': ('setct-CredRevResData', ), + '2.23.42.0.38': ('setct-PCertReqData', ), + '2.23.42.0.39': ('setct-PCertResTBS', ), + '2.23.42.0.40': ('setct-BatchAdminReqData', ), + '2.23.42.0.41': ('setct-BatchAdminResData', ), + '2.23.42.0.42': ('setct-CardCInitResTBS', ), + '2.23.42.0.43': ('setct-MeAqCInitResTBS', ), + '2.23.42.0.44': ('setct-RegFormResTBS', ), + '2.23.42.0.45': ('setct-CertReqData', ), + '2.23.42.0.46': ('setct-CertReqTBS', ), + '2.23.42.0.47': ('setct-CertResData', ), + '2.23.42.0.48': ('setct-CertInqReqTBS', ), + '2.23.42.0.49': ('setct-ErrorTBS', ), + '2.23.42.0.50': ('setct-PIDualSignedTBE', ), + '2.23.42.0.51': ('setct-PIUnsignedTBE', ), + '2.23.42.0.52': ('setct-AuthReqTBE', ), + '2.23.42.0.53': ('setct-AuthResTBE', ), + '2.23.42.0.54': ('setct-AuthResTBEX', ), + '2.23.42.0.55': ('setct-AuthTokenTBE', ), + '2.23.42.0.56': ('setct-CapTokenTBE', ), + '2.23.42.0.57': ('setct-CapTokenTBEX', ), + '2.23.42.0.58': ('setct-AcqCardCodeMsgTBE', ), + '2.23.42.0.59': ('setct-AuthRevReqTBE', ), + '2.23.42.0.60': ('setct-AuthRevResTBE', ), + '2.23.42.0.61': ('setct-AuthRevResTBEB', ), + '2.23.42.0.62': ('setct-CapReqTBE', ), + '2.23.42.0.63': ('setct-CapReqTBEX', ), + '2.23.42.0.64': ('setct-CapResTBE', ), + '2.23.42.0.65': ('setct-CapRevReqTBE', ), + '2.23.42.0.66': ('setct-CapRevReqTBEX', ), + '2.23.42.0.67': ('setct-CapRevResTBE', ), + '2.23.42.0.68': ('setct-CredReqTBE', ), + '2.23.42.0.69': ('setct-CredReqTBEX', ), + '2.23.42.0.70': ('setct-CredResTBE', ), + '2.23.42.0.71': ('setct-CredRevReqTBE', ), + '2.23.42.0.72': ('setct-CredRevReqTBEX', ), + '2.23.42.0.73': ('setct-CredRevResTBE', ), + '2.23.42.0.74': ('setct-BatchAdminReqTBE', ), + '2.23.42.0.75': ('setct-BatchAdminResTBE', ), + '2.23.42.0.76': ('setct-RegFormReqTBE', ), + '2.23.42.0.77': ('setct-CertReqTBE', ), + '2.23.42.0.78': ('setct-CertReqTBEX', ), + '2.23.42.0.79': ('setct-CertResTBE', ), + '2.23.42.0.80': ('setct-CRLNotificationTBS', ), + '2.23.42.0.81': ('setct-CRLNotificationResTBS', ), + '2.23.42.0.82': ('setct-BCIDistributionTBS', ), + '2.23.42.1': ('message extensions', 'set-msgExt'), + '2.23.42.1.1': ('generic cryptogram', 'setext-genCrypt'), + '2.23.42.1.3': ('merchant initiated auth', 'setext-miAuth'), + '2.23.42.1.4': ('setext-pinSecure', ), + '2.23.42.1.5': ('setext-pinAny', ), + '2.23.42.1.7': ('setext-track2', ), + '2.23.42.1.8': ('additional verification', 'setext-cv'), + '2.23.42.3': ('set-attr', ), + '2.23.42.3.0': ('setAttr-Cert', ), + '2.23.42.3.0.0': ('set-rootKeyThumb', ), + '2.23.42.3.0.1': ('set-addPolicy', ), + '2.23.42.3.1': ('payment gateway capabilities', 'setAttr-PGWYcap'), + '2.23.42.3.2': ('setAttr-TokenType', ), + '2.23.42.3.2.1': ('setAttr-Token-EMV', ), + '2.23.42.3.2.2': ('setAttr-Token-B0Prime', ), + '2.23.42.3.3': ('issuer capabilities', 'setAttr-IssCap'), + '2.23.42.3.3.3': ('setAttr-IssCap-CVM', ), + '2.23.42.3.3.3.1': ('generate cryptogram', 'setAttr-GenCryptgrm'), + '2.23.42.3.3.4': ('setAttr-IssCap-T2', ), + '2.23.42.3.3.4.1': ('encrypted track 2', 'setAttr-T2Enc'), + '2.23.42.3.3.4.2': ('cleartext track 2', 'setAttr-T2cleartxt'), + '2.23.42.3.3.5': ('setAttr-IssCap-Sig', ), + '2.23.42.3.3.5.1': ('ICC or token signature', 'setAttr-TokICCsig'), + '2.23.42.3.3.5.2': ('secure device signature', 'setAttr-SecDevSig'), + '2.23.42.5': ('set-policy', ), + '2.23.42.5.0': ('set-policy-root', ), + '2.23.42.7': ('certificate extensions', 'set-certExt'), + '2.23.42.7.0': ('setCext-hashedRoot', ), + '2.23.42.7.1': ('setCext-certType', ), + '2.23.42.7.2': ('setCext-merchData', ), + '2.23.42.7.3': ('setCext-cCertRequired', ), + '2.23.42.7.4': ('setCext-tunneling', ), + '2.23.42.7.5': ('setCext-setExt', ), + '2.23.42.7.6': ('setCext-setQualf', ), + '2.23.42.7.7': ('setCext-PGWYcapabilities', ), + '2.23.42.7.8': ('setCext-TokenIdentifier', ), + '2.23.42.7.9': ('setCext-Track2Data', ), + '2.23.42.7.10': ('setCext-TokenType', ), + '2.23.42.7.11': ('setCext-IssuerCapabilities', ), + '2.23.42.8': ('set-brand', ), + '2.23.42.8.1': ('set-brand-IATA-ATA', ), + '2.23.42.8.4': ('set-brand-Visa', ), + '2.23.42.8.5': ('set-brand-MasterCard', ), + '2.23.42.8.30': ('set-brand-Diners', ), + '2.23.42.8.34': ('set-brand-AmericanExpress', ), + '2.23.42.8.35': ('set-brand-JCB', ), + '2.23.42.8.6011': ('set-brand-Novus', ), + '2.23.43': ('wap', ), + '2.23.43.1': ('wap-wsg', ), + '2.23.43.1.4': ('wap-wsg-idm-ecid', ), + '2.23.43.1.4.1': ('wap-wsg-idm-ecid-wtls1', ), + '2.23.43.1.4.3': ('wap-wsg-idm-ecid-wtls3', ), + '2.23.43.1.4.4': ('wap-wsg-idm-ecid-wtls4', ), + '2.23.43.1.4.5': ('wap-wsg-idm-ecid-wtls5', ), + '2.23.43.1.4.6': ('wap-wsg-idm-ecid-wtls6', ), + '2.23.43.1.4.7': ('wap-wsg-idm-ecid-wtls7', ), + '2.23.43.1.4.8': ('wap-wsg-idm-ecid-wtls8', ), + '2.23.43.1.4.9': ('wap-wsg-idm-ecid-wtls9', ), + '2.23.43.1.4.10': ('wap-wsg-idm-ecid-wtls10', ), + '2.23.43.1.4.11': ('wap-wsg-idm-ecid-wtls11', ), + '2.23.43.1.4.12': ('wap-wsg-idm-ecid-wtls12', ), +} diff --git a/ansible_collections/community/crypto/plugins/module_utils/crypto/basic.py b/ansible_collections/community/crypto/plugins/module_utils/crypto/basic.py new file mode 100644 index 00000000..11c688d2 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/crypto/basic.py @@ -0,0 +1,159 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2016, Yanis Guenane <yanis+ansible@guenane.org> +# 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_collections.community.crypto.plugins.module_utils.version import LooseVersion + +try: + import cryptography + from cryptography import x509 + + # Older versions of cryptography (< 2.1) do not have __hash__ functions for + # general name objects (DNSName, IPAddress, ...), while providing overloaded + # equality and string representation operations. This makes it impossible to + # use them in hash-based data structures such as set or dict. Since we are + # actually doing that in x509_certificate, and potentially in other code, + # we need to monkey-patch __hash__ for these classes to make sure our code + # works fine. + if LooseVersion(cryptography.__version__) < LooseVersion('2.1'): + # A very simply hash function which relies on the representation + # of an object to be implemented. This is the case since at least + # cryptography 1.0, see + # https://github.com/pyca/cryptography/commit/7a9abce4bff36c05d26d8d2680303a6f64a0e84f + def simple_hash(self): + return hash(repr(self)) + + # The hash functions for the following types were added for cryptography 2.1: + # https://github.com/pyca/cryptography/commit/fbfc36da2a4769045f2373b004ddf0aff906cf38 + x509.DNSName.__hash__ = simple_hash + x509.DirectoryName.__hash__ = simple_hash + x509.GeneralName.__hash__ = simple_hash + x509.IPAddress.__hash__ = simple_hash + x509.OtherName.__hash__ = simple_hash + x509.RegisteredID.__hash__ = simple_hash + + if LooseVersion(cryptography.__version__) < LooseVersion('1.2'): + # The hash functions for the following types were added for cryptography 1.2: + # https://github.com/pyca/cryptography/commit/b642deed88a8696e5f01ce6855ccf89985fc35d0 + # https://github.com/pyca/cryptography/commit/d1b5681f6db2bde7a14625538bd7907b08dfb486 + x509.RFC822Name.__hash__ = simple_hash + x509.UniformResourceIdentifier.__hash__ = simple_hash + + # Test whether we have support for DSA, EC, Ed25519, Ed448, RSA, X25519 and/or X448 + try: + # added in 0.5 - https://cryptography.io/en/latest/hazmat/primitives/asymmetric/dsa/ + import cryptography.hazmat.primitives.asymmetric.dsa + CRYPTOGRAPHY_HAS_DSA = True + try: + # added later in 1.5 + cryptography.hazmat.primitives.asymmetric.dsa.DSAPrivateKey.sign + CRYPTOGRAPHY_HAS_DSA_SIGN = True + except AttributeError: + CRYPTOGRAPHY_HAS_DSA_SIGN = False + except ImportError: + CRYPTOGRAPHY_HAS_DSA = False + CRYPTOGRAPHY_HAS_DSA_SIGN = False + try: + # added in 2.6 - https://cryptography.io/en/latest/hazmat/primitives/asymmetric/ed25519/ + import cryptography.hazmat.primitives.asymmetric.ed25519 + CRYPTOGRAPHY_HAS_ED25519 = True + try: + # added with the primitive in 2.6 + cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey.sign + CRYPTOGRAPHY_HAS_ED25519_SIGN = True + except AttributeError: + CRYPTOGRAPHY_HAS_ED25519_SIGN = False + except ImportError: + CRYPTOGRAPHY_HAS_ED25519 = False + CRYPTOGRAPHY_HAS_ED25519_SIGN = False + try: + # added in 2.6 - https://cryptography.io/en/latest/hazmat/primitives/asymmetric/ed448/ + import cryptography.hazmat.primitives.asymmetric.ed448 + CRYPTOGRAPHY_HAS_ED448 = True + try: + # added with the primitive in 2.6 + cryptography.hazmat.primitives.asymmetric.ed448.Ed448PrivateKey.sign + CRYPTOGRAPHY_HAS_ED448_SIGN = True + except AttributeError: + CRYPTOGRAPHY_HAS_ED448_SIGN = False + except ImportError: + CRYPTOGRAPHY_HAS_ED448 = False + CRYPTOGRAPHY_HAS_ED448_SIGN = False + try: + # added in 0.5 - https://cryptography.io/en/latest/hazmat/primitives/asymmetric/ec/ + import cryptography.hazmat.primitives.asymmetric.ec + CRYPTOGRAPHY_HAS_EC = True + try: + # added later in 1.5 + cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey.sign + CRYPTOGRAPHY_HAS_EC_SIGN = True + except AttributeError: + CRYPTOGRAPHY_HAS_EC_SIGN = False + except ImportError: + CRYPTOGRAPHY_HAS_EC = False + CRYPTOGRAPHY_HAS_EC_SIGN = False + try: + # added in 0.5 - https://cryptography.io/en/latest/hazmat/primitives/asymmetric/rsa/ + import cryptography.hazmat.primitives.asymmetric.rsa + CRYPTOGRAPHY_HAS_RSA = True + try: + # added later in 1.4 + cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey.sign + CRYPTOGRAPHY_HAS_RSA_SIGN = True + except AttributeError: + CRYPTOGRAPHY_HAS_RSA_SIGN = False + except ImportError: + CRYPTOGRAPHY_HAS_RSA = False + CRYPTOGRAPHY_HAS_RSA_SIGN = False + try: + # added in 2.0 - https://cryptography.io/en/latest/hazmat/primitives/asymmetric/x25519/ + import cryptography.hazmat.primitives.asymmetric.x25519 + CRYPTOGRAPHY_HAS_X25519 = True + try: + # added later in 2.5 + cryptography.hazmat.primitives.asymmetric.x25519.X25519PrivateKey.private_bytes + CRYPTOGRAPHY_HAS_X25519_FULL = True + except AttributeError: + CRYPTOGRAPHY_HAS_X25519_FULL = False + except ImportError: + CRYPTOGRAPHY_HAS_X25519 = False + CRYPTOGRAPHY_HAS_X25519_FULL = False + try: + # added in 2.5 - https://cryptography.io/en/latest/hazmat/primitives/asymmetric/x448/ + import cryptography.hazmat.primitives.asymmetric.x448 + CRYPTOGRAPHY_HAS_X448 = True + except ImportError: + CRYPTOGRAPHY_HAS_X448 = False + + HAS_CRYPTOGRAPHY = True +except ImportError: + # Error handled in the calling module. + CRYPTOGRAPHY_HAS_EC = False + CRYPTOGRAPHY_HAS_EC_SIGN = False + CRYPTOGRAPHY_HAS_ED25519 = False + CRYPTOGRAPHY_HAS_ED25519_SIGN = False + CRYPTOGRAPHY_HAS_ED448 = False + CRYPTOGRAPHY_HAS_ED448_SIGN = False + CRYPTOGRAPHY_HAS_DSA = False + CRYPTOGRAPHY_HAS_DSA_SIGN = False + CRYPTOGRAPHY_HAS_RSA = False + CRYPTOGRAPHY_HAS_RSA_SIGN = False + CRYPTOGRAPHY_HAS_X25519 = False + CRYPTOGRAPHY_HAS_X25519_FULL = False + CRYPTOGRAPHY_HAS_X448 = False + HAS_CRYPTOGRAPHY = False + + +class OpenSSLObjectError(Exception): + pass + + +class OpenSSLBadPassphraseError(OpenSSLObjectError): + pass diff --git a/ansible_collections/community/crypto/plugins/module_utils/crypto/cryptography_crl.py b/ansible_collections/community/crypto/plugins/module_utils/crypto/cryptography_crl.py new file mode 100644 index 00000000..62499e08 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/crypto/cryptography_crl.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2019, 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 + + +try: + from cryptography import x509 +except ImportError: + # Error handled in the calling module. + pass + +from .basic import ( + HAS_CRYPTOGRAPHY, +) + +from .cryptography_support import ( + cryptography_decode_name, +) + +from ._obj2txt import ( + obj2txt, +) + + +TIMESTAMP_FORMAT = "%Y%m%d%H%M%SZ" + + +if HAS_CRYPTOGRAPHY: + REVOCATION_REASON_MAP = { + 'unspecified': x509.ReasonFlags.unspecified, + 'key_compromise': x509.ReasonFlags.key_compromise, + 'ca_compromise': x509.ReasonFlags.ca_compromise, + 'affiliation_changed': x509.ReasonFlags.affiliation_changed, + 'superseded': x509.ReasonFlags.superseded, + 'cessation_of_operation': x509.ReasonFlags.cessation_of_operation, + 'certificate_hold': x509.ReasonFlags.certificate_hold, + 'privilege_withdrawn': x509.ReasonFlags.privilege_withdrawn, + 'aa_compromise': x509.ReasonFlags.aa_compromise, + 'remove_from_crl': x509.ReasonFlags.remove_from_crl, + } + REVOCATION_REASON_MAP_INVERSE = dict() + for k, v in REVOCATION_REASON_MAP.items(): + REVOCATION_REASON_MAP_INVERSE[v] = k + +else: + REVOCATION_REASON_MAP = dict() + REVOCATION_REASON_MAP_INVERSE = dict() + + +def cryptography_decode_revoked_certificate(cert): + result = { + 'serial_number': cert.serial_number, + 'revocation_date': cert.revocation_date, + 'issuer': None, + 'issuer_critical': False, + 'reason': None, + 'reason_critical': False, + 'invalidity_date': None, + 'invalidity_date_critical': False, + } + try: + ext = cert.extensions.get_extension_for_class(x509.CertificateIssuer) + result['issuer'] = list(ext.value) + result['issuer_critical'] = ext.critical + except x509.ExtensionNotFound: + pass + try: + ext = cert.extensions.get_extension_for_class(x509.CRLReason) + result['reason'] = ext.value.reason + result['reason_critical'] = ext.critical + except x509.ExtensionNotFound: + pass + try: + ext = cert.extensions.get_extension_for_class(x509.InvalidityDate) + result['invalidity_date'] = ext.value.invalidity_date + result['invalidity_date_critical'] = ext.critical + except x509.ExtensionNotFound: + pass + return result + + +def cryptography_dump_revoked(entry, idn_rewrite='ignore'): + return { + 'serial_number': entry['serial_number'], + 'revocation_date': entry['revocation_date'].strftime(TIMESTAMP_FORMAT), + 'issuer': + [cryptography_decode_name(issuer, idn_rewrite=idn_rewrite) for issuer in entry['issuer']] + if entry['issuer'] is not None else None, + 'issuer_critical': entry['issuer_critical'], + 'reason': REVOCATION_REASON_MAP_INVERSE.get(entry['reason']) if entry['reason'] is not None else None, + 'reason_critical': entry['reason_critical'], + 'invalidity_date': + entry['invalidity_date'].strftime(TIMESTAMP_FORMAT) + if entry['invalidity_date'] is not None else None, + 'invalidity_date_critical': entry['invalidity_date_critical'], + } + + +def cryptography_get_signature_algorithm_oid_from_crl(crl): + try: + return crl.signature_algorithm_oid + except AttributeError: + # Older cryptography versions do not have signature_algorithm_oid yet + dotted = obj2txt( + crl._backend._lib, + crl._backend._ffi, + crl._x509_crl.sig_alg.algorithm + ) + return x509.oid.ObjectIdentifier(dotted) diff --git a/ansible_collections/community/crypto/plugins/module_utils/crypto/cryptography_support.py b/ansible_collections/community/crypto/plugins/module_utils/crypto/cryptography_support.py new file mode 100644 index 00000000..fde69199 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/crypto/cryptography_support.py @@ -0,0 +1,809 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2019, 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 base64 +import binascii +import re +import sys +import traceback + +from ansible.module_utils.common.text.converters import to_text, to_bytes, to_native +from ansible.module_utils.six.moves.urllib.parse import urlparse, urlunparse, ParseResult + +from ._asn1 import serialize_asn1_string_as_der + +from ansible_collections.community.crypto.plugins.module_utils.version import LooseVersion + +try: + import cryptography + from cryptography import x509 + from cryptography.exceptions import InvalidSignature + from cryptography.hazmat.backends import default_backend + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import padding + import ipaddress +except ImportError: + # Error handled in the calling module. + pass + +try: + import cryptography.hazmat.primitives.asymmetric.rsa +except ImportError: + pass +try: + import cryptography.hazmat.primitives.asymmetric.ec +except ImportError: + pass +try: + import cryptography.hazmat.primitives.asymmetric.dsa +except ImportError: + pass +try: + import cryptography.hazmat.primitives.asymmetric.ed25519 +except ImportError: + pass +try: + import cryptography.hazmat.primitives.asymmetric.ed448 +except ImportError: + pass + +try: + # This is a separate try/except since this is only present in cryptography 2.5 or newer + from cryptography.hazmat.primitives.serialization.pkcs12 import ( + load_key_and_certificates as _load_key_and_certificates, + ) +except ImportError: + # Error handled in the calling module. + _load_key_and_certificates = None + +try: + # This is a separate try/except since this is only present in cryptography 36.0.0 or newer + from cryptography.hazmat.primitives.serialization.pkcs12 import ( + load_pkcs12 as _load_pkcs12, + ) +except ImportError: + # Error handled in the calling module. + _load_pkcs12 = None + +try: + import idna + + HAS_IDNA = True +except ImportError: + HAS_IDNA = False + IDNA_IMP_ERROR = traceback.format_exc() + +from ansible.module_utils.basic import missing_required_lib + +from .basic import ( + CRYPTOGRAPHY_HAS_DSA_SIGN, + CRYPTOGRAPHY_HAS_EC_SIGN, + CRYPTOGRAPHY_HAS_ED25519, + CRYPTOGRAPHY_HAS_ED25519_SIGN, + CRYPTOGRAPHY_HAS_ED448, + CRYPTOGRAPHY_HAS_ED448_SIGN, + CRYPTOGRAPHY_HAS_RSA_SIGN, + CRYPTOGRAPHY_HAS_X25519, + CRYPTOGRAPHY_HAS_X25519_FULL, + CRYPTOGRAPHY_HAS_X448, + OpenSSLObjectError, +) + +from ._objects import ( + OID_LOOKUP, + OID_MAP, + NORMALIZE_NAMES_SHORT, + NORMALIZE_NAMES, +) + +from ._obj2txt import obj2txt + + +DOTTED_OID = re.compile(r'^\d+(?:\.\d+)+$') + + +def cryptography_get_extensions_from_cert(cert): + result = dict() + try: + # Since cryptography will not give us the DER value for an extension + # (that is only stored for unrecognized extensions), we have to re-do + # the extension parsing outselves. + backend = default_backend() + try: + # For certain old versions of cryptography, backend is a MultiBackend object, + # which has no _lib attribute. In that case, revert to the old approach. + backend._lib + except AttributeError: + backend = cert._backend + + x509_obj = cert._x509 + # With cryptography 35.0.0, we can no longer use obj2txt. Unfortunately it still does + # not allow to get the raw value of an extension, so we have to use this ugly hack: + exts = list(cert.extensions) + + for i in range(backend._lib.X509_get_ext_count(x509_obj)): + ext = backend._lib.X509_get_ext(x509_obj, i) + if ext == backend._ffi.NULL: + continue + crit = backend._lib.X509_EXTENSION_get_critical(ext) + data = backend._lib.X509_EXTENSION_get_data(ext) + backend.openssl_assert(data != backend._ffi.NULL) + der = backend._ffi.buffer(data.data, data.length)[:] + entry = dict( + critical=(crit == 1), + value=to_native(base64.b64encode(der)), + ) + try: + oid = obj2txt(backend._lib, backend._ffi, backend._lib.X509_EXTENSION_get_object(ext)) + except AttributeError: + oid = exts[i].oid.dotted_string + result[oid] = entry + + except Exception: + # In case the above method breaks, we likely have cryptography 36.0.0 or newer. + # Use it's public_bytes() feature in that case. We will later switch this around + # so that this code will be the default, but for now this will act as a fallback + # since it will re-serialize de-serialized data, which can be different (if the + # original data was not canonicalized) from what was contained in the certificate. + for ext in cert.extensions: + result[ext.oid.dotted_string] = dict( + critical=ext.critical, + value=to_native(base64.b64encode(ext.value.public_bytes())), + ) + + return result + + +def cryptography_get_extensions_from_csr(csr): + result = dict() + try: + # Since cryptography will not give us the DER value for an extension + # (that is only stored for unrecognized extensions), we have to re-do + # the extension parsing outselves. + backend = default_backend() + try: + # For certain old versions of cryptography, backend is a MultiBackend object, + # which has no _lib attribute. In that case, revert to the old approach. + backend._lib + except AttributeError: + backend = csr._backend + + extensions = backend._lib.X509_REQ_get_extensions(csr._x509_req) + extensions = backend._ffi.gc( + extensions, + lambda ext: backend._lib.sk_X509_EXTENSION_pop_free( + ext, + backend._ffi.addressof(backend._lib._original_lib, "X509_EXTENSION_free") + ) + ) + + # With cryptography 35.0.0, we can no longer use obj2txt. Unfortunately it still does + # not allow to get the raw value of an extension, so we have to use this ugly hack: + exts = list(csr.extensions) + + for i in range(backend._lib.sk_X509_EXTENSION_num(extensions)): + ext = backend._lib.sk_X509_EXTENSION_value(extensions, i) + if ext == backend._ffi.NULL: + continue + crit = backend._lib.X509_EXTENSION_get_critical(ext) + data = backend._lib.X509_EXTENSION_get_data(ext) + backend.openssl_assert(data != backend._ffi.NULL) + der = backend._ffi.buffer(data.data, data.length)[:] + entry = dict( + critical=(crit == 1), + value=to_native(base64.b64encode(der)), + ) + try: + oid = obj2txt(backend._lib, backend._ffi, backend._lib.X509_EXTENSION_get_object(ext)) + except AttributeError: + oid = exts[i].oid.dotted_string + result[oid] = entry + + except Exception: + # In case the above method breaks, we likely have cryptography 36.0.0 or newer. + # Use it's public_bytes() feature in that case. We will later switch this around + # so that this code will be the default, but for now this will act as a fallback + # since it will re-serialize de-serialized data, which can be different (if the + # original data was not canonicalized) from what was contained in the CSR. + for ext in csr.extensions: + result[ext.oid.dotted_string] = dict( + critical=ext.critical, + value=to_native(base64.b64encode(ext.value.public_bytes())), + ) + + return result + + +def cryptography_name_to_oid(name): + dotted = OID_LOOKUP.get(name) + if dotted is None: + if DOTTED_OID.match(name): + return x509.oid.ObjectIdentifier(name) + raise OpenSSLObjectError('Cannot find OID for "{0}"'.format(name)) + return x509.oid.ObjectIdentifier(dotted) + + +def cryptography_oid_to_name(oid, short=False): + dotted_string = oid.dotted_string + names = OID_MAP.get(dotted_string) + if names: + name = names[0] + else: + name = oid._name + if name == 'Unknown OID': + name = dotted_string + if short: + return NORMALIZE_NAMES_SHORT.get(name, name) + else: + return NORMALIZE_NAMES.get(name, name) + + +def _get_hex(bytesstr): + if bytesstr is None: + return bytesstr + data = binascii.hexlify(bytesstr) + data = to_text(b':'.join(data[i:i + 2] for i in range(0, len(data), 2))) + return data + + +def _parse_hex(bytesstr): + if bytesstr is None: + return bytesstr + data = ''.join([('0' * (2 - len(p)) + p) if len(p) < 2 else p for p in to_text(bytesstr).split(':')]) + data = binascii.unhexlify(data) + return data + + +DN_COMPONENT_START_RE = re.compile(b'^ *([a-zA-z0-9.]+) *= *') +DN_HEX_LETTER = b'0123456789abcdef' + + +if sys.version_info[0] < 3: + _int_to_byte = chr +else: + def _int_to_byte(value): + return bytes((value, )) + + +def _parse_dn_component(name, sep=b',', decode_remainder=True): + m = DN_COMPONENT_START_RE.match(name) + if not m: + raise OpenSSLObjectError(u'cannot start part in "{0}"'.format(to_text(name))) + oid = cryptography_name_to_oid(to_text(m.group(1))) + idx = len(m.group(0)) + decoded_name = [] + sep_str = sep + b'\\' + if decode_remainder: + length = len(name) + if length > idx and name[idx:idx + 1] == b'#': + # Decoding a hex string + idx += 1 + while idx + 1 < length: + ch1 = name[idx:idx + 1] + ch2 = name[idx + 1:idx + 2] + idx1 = DN_HEX_LETTER.find(ch1.lower()) + idx2 = DN_HEX_LETTER.find(ch2.lower()) + if idx1 < 0 or idx2 < 0: + raise OpenSSLObjectError(u'Invalid hex sequence entry "{0}"'.format(to_text(ch1 + ch2))) + idx += 2 + decoded_name.append(_int_to_byte(idx1 * 16 + idx2)) + else: + # Decoding a regular string + while idx < length: + i = idx + while i < length and name[i:i + 1] not in sep_str: + i += 1 + if i > idx: + decoded_name.append(name[idx:i]) + idx = i + while idx + 1 < length and name[idx:idx + 1] == b'\\': + ch = name[idx + 1:idx + 2] + idx1 = DN_HEX_LETTER.find(ch.lower()) + if idx1 >= 0: + if idx + 2 >= length: + raise OpenSSLObjectError(u'Hex escape sequence "\\{0}" incomplete at end of string'.format(to_text(ch))) + ch2 = name[idx + 2:idx + 3] + idx2 = DN_HEX_LETTER.find(ch2.lower()) + if idx2 < 0: + raise OpenSSLObjectError(u'Hex escape sequence "\\{0}" has invalid second letter'.format(to_text(ch + ch2))) + ch = _int_to_byte(idx1 * 16 + idx2) + idx += 1 + idx += 2 + decoded_name.append(ch) + if idx < length and name[idx:idx + 1] == sep: + break + else: + decoded_name.append(name[idx:]) + idx = len(name) + return x509.NameAttribute(oid, to_text(b''.join(decoded_name))), name[idx:] + + +def _parse_dn(name): + ''' + Parse a Distinguished Name. + + Can be of the form ``CN=Test, O = Something`` or ``CN = Test,O= Something``. + ''' + original_name = name + name = name.lstrip() + sep = b',' + if name.startswith(b'/'): + sep = b'/' + name = name[1:] + result = [] + while name: + try: + attribute, name = _parse_dn_component(name, sep=sep) + except OpenSSLObjectError as e: + raise OpenSSLObjectError(u'Error while parsing distinguished name "{0}": {1}'.format(to_text(original_name), e)) + result.append(attribute) + if name: + if name[0:1] != sep or len(name) < 2: + raise OpenSSLObjectError(u'Error while parsing distinguished name "{0}": unexpected end of string'.format(to_text(original_name))) + name = name[1:] + return result + + +def cryptography_parse_relative_distinguished_name(rdn): + names = [] + for part in rdn: + try: + names.append(_parse_dn_component(to_bytes(part), decode_remainder=False)[0]) + except OpenSSLObjectError as e: + raise OpenSSLObjectError(u'Error while parsing relative distinguished name "{0}": {1}'.format(part, e)) + return cryptography.x509.RelativeDistinguishedName(names) + + +def _is_ascii(value): + '''Check whether the Unicode string `value` contains only ASCII characters.''' + try: + value.encode("ascii") + return True + except UnicodeEncodeError: + return False + + +def _adjust_idn(value, idn_rewrite): + if idn_rewrite == 'ignore' or not value: + return value + if idn_rewrite == 'idna' and _is_ascii(value): + return value + if idn_rewrite not in ('idna', 'unicode'): + raise ValueError('Invalid value for idn_rewrite: "{0}"'.format(idn_rewrite)) + if not HAS_IDNA: + raise OpenSSLObjectError( + missing_required_lib('idna', reason='to transform {what} DNS name "{name}" to {dest}'.format( + name=value, + what='IDNA' if idn_rewrite == 'unicode' else 'Unicode', + dest='Unicode' if idn_rewrite == 'unicode' else 'IDNA', + ))) + # Since IDNA does not like '*' or empty labels (except one empty label at the end), + # we split and let IDNA only handle labels that are neither empty or '*'. + parts = value.split(u'.') + for index, part in enumerate(parts): + if part in (u'', u'*'): + continue + try: + if idn_rewrite == 'idna': + parts[index] = idna.encode(part).decode('ascii') + elif idn_rewrite == 'unicode' and part.startswith(u'xn--'): + parts[index] = idna.decode(part) + except idna.IDNAError as exc2008: + try: + if idn_rewrite == 'idna': + parts[index] = part.encode('idna').decode('ascii') + elif idn_rewrite == 'unicode' and part.startswith(u'xn--'): + parts[index] = part.encode('ascii').decode('idna') + except Exception as exc2003: + raise OpenSSLObjectError( + u'Error while transforming part "{part}" of {what} DNS name "{name}" to {dest}.' + u' IDNA2008 transformation resulted in "{exc2008}", IDNA2003 transformation resulted in "{exc2003}".'.format( + part=part, + name=value, + what='IDNA' if idn_rewrite == 'unicode' else 'Unicode', + dest='Unicode' if idn_rewrite == 'unicode' else 'IDNA', + exc2003=exc2003, + exc2008=exc2008, + )) + return u'.'.join(parts) + + +def _adjust_idn_email(value, idn_rewrite): + idx = value.find(u'@') + if idx < 0: + return value + return u'{0}@{1}'.format(value[:idx], _adjust_idn(value[idx + 1:], idn_rewrite)) + + +def _adjust_idn_url(value, idn_rewrite): + url = urlparse(value) + host = _adjust_idn(url.hostname, idn_rewrite) + if url.username is not None and url.password is not None: + host = u'{0}:{1}@{2}'.format(url.username, url.password, host) + elif url.username is not None: + host = u'{0}@{1}'.format(url.username, host) + if url.port is not None: + host = u'{0}:{1}'.format(host, url.port) + return urlunparse( + ParseResult(scheme=url.scheme, netloc=host, path=url.path, params=url.params, query=url.query, fragment=url.fragment)) + + +def cryptography_get_name(name, what='Subject Alternative Name'): + ''' + Given a name string, returns a cryptography x509.GeneralName object. + Raises an OpenSSLObjectError if the name is unknown or cannot be parsed. + ''' + try: + if name.startswith('DNS:'): + return x509.DNSName(_adjust_idn(to_text(name[4:]), 'idna')) + if name.startswith('IP:'): + address = to_text(name[3:]) + if '/' in address: + return x509.IPAddress(ipaddress.ip_network(address)) + return x509.IPAddress(ipaddress.ip_address(address)) + if name.startswith('email:'): + return x509.RFC822Name(_adjust_idn_email(to_text(name[6:]), 'idna')) + if name.startswith('URI:'): + return x509.UniformResourceIdentifier(_adjust_idn_url(to_text(name[4:]), 'idna')) + if name.startswith('RID:'): + m = re.match(r'^([0-9]+(?:\.[0-9]+)*)$', to_text(name[4:])) + if not m: + raise OpenSSLObjectError('Cannot parse {what} "{name}"'.format(name=name, what=what)) + return x509.RegisteredID(x509.oid.ObjectIdentifier(m.group(1))) + if name.startswith('otherName:'): + # otherName can either be a raw ASN.1 hex string or in the format that OpenSSL works with. + m = re.match(r'^([0-9]+(?:\.[0-9]+)*);([0-9a-fA-F]{1,2}(?::[0-9a-fA-F]{1,2})*)$', to_text(name[10:])) + if m: + return x509.OtherName(x509.oid.ObjectIdentifier(m.group(1)), _parse_hex(m.group(2))) + + # See https://www.openssl.org/docs/man1.0.2/man5/x509v3_config.html - Subject Alternative Name for more + # defailts on the format expected. + name = to_text(name[10:], errors='surrogate_or_strict') + if ';' not in name: + raise OpenSSLObjectError('Cannot parse {what} otherName "{name}", must be in the ' + 'format "otherName:<OID>;<ASN.1 OpenSSL Encoded String>" or ' + '"otherName:<OID>;<hex string>"'.format(name=name, what=what)) + + oid, value = name.split(';', 1) + b_value = serialize_asn1_string_as_der(value) + return x509.OtherName(x509.ObjectIdentifier(oid), b_value) + if name.startswith('dirName:'): + return x509.DirectoryName(x509.Name(reversed(_parse_dn(to_bytes(name[8:]))))) + except Exception as e: + raise OpenSSLObjectError('Cannot parse {what} "{name}": {error}'.format(name=name, what=what, error=e)) + if ':' not in name: + raise OpenSSLObjectError('Cannot parse {what} "{name}" (forgot "DNS:" prefix?)'.format(name=name, what=what)) + raise OpenSSLObjectError('Cannot parse {what} "{name}" (potentially unsupported by cryptography backend)'.format(name=name, what=what)) + + +def _dn_escape_value(value): + ''' + Escape Distinguished Name's attribute value. + ''' + value = value.replace(u'\\', u'\\\\') + for ch in [u',', u'+', u'<', u'>', u';', u'"']: + value = value.replace(ch, u'\\%s' % ch) + value = value.replace(u'\0', u'\\00') + if value.startswith((u' ', u'#')): + value = u'\\%s' % value[0] + value[1:] + if value.endswith(u' '): + value = value[:-1] + u'\\ ' + return value + + +def cryptography_decode_name(name, idn_rewrite='ignore'): + ''' + Given a cryptography x509.GeneralName object, returns a string. + Raises an OpenSSLObjectError if the name is not supported. + ''' + if idn_rewrite not in ('ignore', 'idna', 'unicode'): + raise AssertionError('idn_rewrite must be one of "ignore", "idna", or "unicode"') + if isinstance(name, x509.DNSName): + return u'DNS:{0}'.format(_adjust_idn(name.value, idn_rewrite)) + if isinstance(name, x509.IPAddress): + if isinstance(name.value, (ipaddress.IPv4Network, ipaddress.IPv6Network)): + return u'IP:{0}/{1}'.format(name.value.network_address.compressed, name.value.prefixlen) + return u'IP:{0}'.format(name.value.compressed) + if isinstance(name, x509.RFC822Name): + return u'email:{0}'.format(_adjust_idn_email(name.value, idn_rewrite)) + if isinstance(name, x509.UniformResourceIdentifier): + return u'URI:{0}'.format(_adjust_idn_url(name.value, idn_rewrite)) + if isinstance(name, x509.DirectoryName): + # According to https://datatracker.ietf.org/doc/html/rfc4514.html#section-2.1 the + # list needs to be reversed, and joined by commas + return u'dirName:' + ','.join([ + u'{0}={1}'.format(to_text(cryptography_oid_to_name(attribute.oid, short=True)), _dn_escape_value(attribute.value)) + for attribute in reversed(list(name.value)) + ]) + if isinstance(name, x509.RegisteredID): + return u'RID:{0}'.format(name.value.dotted_string) + if isinstance(name, x509.OtherName): + return u'otherName:{0};{1}'.format(name.type_id.dotted_string, _get_hex(name.value)) + raise OpenSSLObjectError('Cannot decode name "{0}"'.format(name)) + + +def _cryptography_get_keyusage(usage): + ''' + Given a key usage identifier string, returns the parameter name used by cryptography's x509.KeyUsage(). + Raises an OpenSSLObjectError if the identifier is unknown. + ''' + if usage in ('Digital Signature', 'digitalSignature'): + return 'digital_signature' + if usage in ('Non Repudiation', 'nonRepudiation'): + return 'content_commitment' + if usage in ('Key Encipherment', 'keyEncipherment'): + return 'key_encipherment' + if usage in ('Data Encipherment', 'dataEncipherment'): + return 'data_encipherment' + if usage in ('Key Agreement', 'keyAgreement'): + return 'key_agreement' + if usage in ('Certificate Sign', 'keyCertSign'): + return 'key_cert_sign' + if usage in ('CRL Sign', 'cRLSign'): + return 'crl_sign' + if usage in ('Encipher Only', 'encipherOnly'): + return 'encipher_only' + if usage in ('Decipher Only', 'decipherOnly'): + return 'decipher_only' + raise OpenSSLObjectError('Unknown key usage "{0}"'.format(usage)) + + +def cryptography_parse_key_usage_params(usages): + ''' + Given a list of key usage identifier strings, returns the parameters for cryptography's x509.KeyUsage(). + Raises an OpenSSLObjectError if an identifier is unknown. + ''' + params = dict( + digital_signature=False, + content_commitment=False, + key_encipherment=False, + data_encipherment=False, + key_agreement=False, + key_cert_sign=False, + crl_sign=False, + encipher_only=False, + decipher_only=False, + ) + for usage in usages: + params[_cryptography_get_keyusage(usage)] = True + return params + + +def cryptography_get_basic_constraints(constraints): + ''' + Given a list of constraints, returns a tuple (ca, path_length). + Raises an OpenSSLObjectError if a constraint is unknown or cannot be parsed. + ''' + ca = False + path_length = None + if constraints: + for constraint in constraints: + if constraint.startswith('CA:'): + if constraint == 'CA:TRUE': + ca = True + elif constraint == 'CA:FALSE': + ca = False + else: + raise OpenSSLObjectError('Unknown basic constraint value "{0}" for CA'.format(constraint[3:])) + elif constraint.startswith('pathlen:'): + v = constraint[len('pathlen:'):] + try: + path_length = int(v) + except Exception as e: + raise OpenSSLObjectError('Cannot parse path length constraint "{0}" ({1})'.format(v, e)) + else: + raise OpenSSLObjectError('Unknown basic constraint "{0}"'.format(constraint)) + return ca, path_length + + +def cryptography_key_needs_digest_for_signing(key): + '''Tests whether the given private key requires a digest algorithm for signing. + + Ed25519 and Ed448 keys do not; they need None to be passed as the digest algorithm. + ''' + if CRYPTOGRAPHY_HAS_ED25519 and isinstance(key, cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey): + return False + if CRYPTOGRAPHY_HAS_ED448 and isinstance(key, cryptography.hazmat.primitives.asymmetric.ed448.Ed448PrivateKey): + return False + return True + + +def _compare_public_keys(key1, key2, clazz): + a = isinstance(key1, clazz) + b = isinstance(key2, clazz) + if not (a or b): + return None + if not a or not b: + return False + a = key1.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) + b = key2.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) + return a == b + + +def cryptography_compare_public_keys(key1, key2): + '''Tests whether two public keys are the same. + + Needs special logic for Ed25519 and Ed448 keys, since they do not have public_numbers(). + ''' + if CRYPTOGRAPHY_HAS_ED25519: + res = _compare_public_keys(key1, key2, cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PublicKey) + if res is not None: + return res + if CRYPTOGRAPHY_HAS_ED448: + res = _compare_public_keys(key1, key2, cryptography.hazmat.primitives.asymmetric.ed448.Ed448PublicKey) + if res is not None: + return res + return key1.public_numbers() == key2.public_numbers() + + +def _compare_private_keys(key1, key2, clazz, has_no_private_bytes=False): + a = isinstance(key1, clazz) + b = isinstance(key2, clazz) + if not (a or b): + return None + if not a or not b: + return False + if has_no_private_bytes: + # We do not have the private_bytes() function - compare associated public keys + return cryptography_compare_public_keys(a.public_key(), b.public_key()) + encryption_algorithm = cryptography.hazmat.primitives.serialization.NoEncryption() + a = key1.private_bytes(serialization.Encoding.Raw, serialization.PrivateFormat.Raw, encryption_algorithm=encryption_algorithm) + b = key2.private_bytes(serialization.Encoding.Raw, serialization.PrivateFormat.Raw, encryption_algorithm=encryption_algorithm) + return a == b + + +def cryptography_compare_private_keys(key1, key2): + '''Tests whether two private keys are the same. + + Needs special logic for Ed25519, X25519, and Ed448 keys, since they do not have private_numbers(). + ''' + if CRYPTOGRAPHY_HAS_ED25519: + res = _compare_private_keys(key1, key2, cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey) + if res is not None: + return res + if CRYPTOGRAPHY_HAS_X25519: + res = _compare_private_keys( + key1, key2, cryptography.hazmat.primitives.asymmetric.x25519.X25519PrivateKey, has_no_private_bytes=not CRYPTOGRAPHY_HAS_X25519_FULL) + if res is not None: + return res + if CRYPTOGRAPHY_HAS_ED448: + res = _compare_private_keys(key1, key2, cryptography.hazmat.primitives.asymmetric.ed448.Ed448PrivateKey) + if res is not None: + return res + if CRYPTOGRAPHY_HAS_X448: + res = _compare_private_keys(key1, key2, cryptography.hazmat.primitives.asymmetric.x448.X448PrivateKey) + if res is not None: + return res + return key1.private_numbers() == key2.private_numbers() + + +def cryptography_serial_number_of_cert(cert): + '''Returns cert.serial_number. + + Also works for old versions of cryptography. + ''' + try: + return cert.serial_number + except AttributeError: + # The property was called "serial" before cryptography 1.4 + return cert.serial + + +def parse_pkcs12(pkcs12_bytes, passphrase=None): + '''Returns a tuple (private_key, certificate, additional_certificates, friendly_name). + ''' + if _load_pkcs12 is None and _load_key_and_certificates is None: + raise ValueError('neither load_pkcs12() nor load_key_and_certificates() present in the current cryptography version') + + if passphrase is not None: + passphrase = to_bytes(passphrase) + + # Main code for cryptography 36.0.0 and forward + if _load_pkcs12 is not None: + return _parse_pkcs12_36_0_0(pkcs12_bytes, passphrase) + + if LooseVersion(cryptography.__version__) >= LooseVersion('35.0'): + return _parse_pkcs12_35_0_0(pkcs12_bytes, passphrase) + + return _parse_pkcs12_legacy(pkcs12_bytes, passphrase) + + +def _parse_pkcs12_36_0_0(pkcs12_bytes, passphrase=None): + # Requires cryptography 36.0.0 or newer + pkcs12 = _load_pkcs12(pkcs12_bytes, passphrase) + additional_certificates = [cert.certificate for cert in pkcs12.additional_certs] + private_key = pkcs12.key + certificate = None + friendly_name = None + if pkcs12.cert: + certificate = pkcs12.cert.certificate + friendly_name = pkcs12.cert.friendly_name + return private_key, certificate, additional_certificates, friendly_name + + +def _parse_pkcs12_35_0_0(pkcs12_bytes, passphrase=None): + # Backwards compatibility code for cryptography 35.x + private_key, certificate, additional_certificates = _load_key_and_certificates(pkcs12_bytes, passphrase) + + friendly_name = None + if certificate: + # See https://github.com/pyca/cryptography/issues/5760#issuecomment-842687238 + backend = default_backend() + + # This code basically does what load_key_and_certificates() does, but without error-checking. + # Since load_key_and_certificates succeeded, it should not fail. + pkcs12 = backend._ffi.gc( + backend._lib.d2i_PKCS12_bio(backend._bytes_to_bio(pkcs12_bytes).bio, backend._ffi.NULL), + backend._lib.PKCS12_free) + certificate_x509_ptr = backend._ffi.new("X509 **") + with backend._zeroed_null_terminated_buf(to_bytes(passphrase) if passphrase is not None else None) as passphrase_buffer: + backend._lib.PKCS12_parse( + pkcs12, + passphrase_buffer, + backend._ffi.new("EVP_PKEY **"), + certificate_x509_ptr, + backend._ffi.new("Cryptography_STACK_OF_X509 **")) + if certificate_x509_ptr[0] != backend._ffi.NULL: + maybe_name = backend._lib.X509_alias_get0(certificate_x509_ptr[0], backend._ffi.NULL) + if maybe_name != backend._ffi.NULL: + friendly_name = backend._ffi.string(maybe_name) + + return private_key, certificate, additional_certificates, friendly_name + + +def _parse_pkcs12_legacy(pkcs12_bytes, passphrase=None): + # Backwards compatibility code for cryptography < 35.0.0 + private_key, certificate, additional_certificates = _load_key_and_certificates(pkcs12_bytes, passphrase) + + friendly_name = None + if certificate: + # See https://github.com/pyca/cryptography/issues/5760#issuecomment-842687238 + backend = certificate._backend + maybe_name = backend._lib.X509_alias_get0(certificate._x509, backend._ffi.NULL) + if maybe_name != backend._ffi.NULL: + friendly_name = backend._ffi.string(maybe_name) + return private_key, certificate, additional_certificates, friendly_name + + +def cryptography_verify_signature(signature, data, hash_algorithm, signer_public_key): + ''' + Check whether the given signature of the given data was signed by the given public key object. + ''' + try: + if CRYPTOGRAPHY_HAS_RSA_SIGN and isinstance(signer_public_key, cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey): + signer_public_key.verify(signature, data, padding.PKCS1v15(), hash_algorithm) + return True + if CRYPTOGRAPHY_HAS_EC_SIGN and isinstance(signer_public_key, cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePublicKey): + signer_public_key.verify(signature, data, cryptography.hazmat.primitives.asymmetric.ec.ECDSA(hash_algorithm)) + return True + if CRYPTOGRAPHY_HAS_DSA_SIGN and isinstance(signer_public_key, cryptography.hazmat.primitives.asymmetric.dsa.DSAPublicKey): + signer_public_key.verify(signature, data, hash_algorithm) + return True + if CRYPTOGRAPHY_HAS_ED25519_SIGN and isinstance(signer_public_key, cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PublicKey): + signer_public_key.verify(signature, data) + return True + if CRYPTOGRAPHY_HAS_ED448_SIGN and isinstance(signer_public_key, cryptography.hazmat.primitives.asymmetric.ed448.Ed448PublicKey): + signer_public_key.verify(signature, data) + return True + raise OpenSSLObjectError(u'Unsupported public key type {0}'.format(type(signer_public_key))) + except InvalidSignature: + return False + + +def cryptography_verify_certificate_signature(certificate, signer_public_key): + ''' + Check whether the given X509 certificate object was signed by the given public key object. + ''' + return cryptography_verify_signature( + certificate.signature, + certificate.tbs_certificate_bytes, + certificate.signature_hash_algorithm, + signer_public_key + ) diff --git a/ansible_collections/community/crypto/plugins/module_utils/crypto/math.py b/ansible_collections/community/crypto/plugins/module_utils/crypto/math.py new file mode 100644 index 00000000..1cfe38b9 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/crypto/math.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2019, 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 sys + + +def binary_exp_mod(f, e, m): + '''Computes f^e mod m in O(log e) multiplications modulo m.''' + # Compute len_e = floor(log_2(e)) + len_e = -1 + x = e + while x > 0: + x >>= 1 + len_e += 1 + # Compute f**e mod m + result = 1 + for k in range(len_e, -1, -1): + result = (result * result) % m + if ((e >> k) & 1) != 0: + result = (result * f) % m + return result + + +def simple_gcd(a, b): + '''Compute GCD of its two inputs.''' + while b != 0: + a, b = b, a % b + return a + + +def quick_is_not_prime(n): + '''Does some quick checks to see if we can poke a hole into the primality of n. + + A result of `False` does **not** mean that the number is prime; it just means + that we could not detect quickly whether it is not prime. + ''' + if n <= 2: + return True + # The constant in the next line is the product of all primes < 200 + if simple_gcd(n, 7799922041683461553249199106329813876687996789903550945093032474868511536164700810) > 1: + return True + # TODO: maybe do some iterations of Miller-Rabin to increase confidence + # (https://en.wikipedia.org/wiki/Miller%E2%80%93Rabin_primality_test) + return False + + +python_version = (sys.version_info[0], sys.version_info[1]) +if python_version >= (2, 7) or python_version >= (3, 1): + # Ansible still supports Python 2.6 on remote nodes + def count_bits(no): + no = abs(no) + if no == 0: + return 0 + return no.bit_length() +else: + # Slow, but works + def count_bits(no): + no = abs(no) + count = 0 + while no > 0: + no >>= 1 + count += 1 + return count 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 new file mode 100644 index 00000000..7a56d7e9 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/certificate.py @@ -0,0 +1,354 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2016-2017, Yanis Guenane <yanis+ansible@guenane.org> +# Copyright (c) 2017, Markus Teufelberger <mteufelberger+ansible@mgit.at> +# 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 abc +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.crypto.module_backends.common import ArgumentSpec + +from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import ( + OpenSSLObjectError, + OpenSSLBadPassphraseError, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.support import ( + load_privatekey, + load_certificate, + load_certificate_request, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_support import ( + cryptography_compare_public_keys, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.certificate_info import ( + get_certificate_info, +) + +MINIMAL_CRYPTOGRAPHY_VERSION = '1.6' + +CRYPTOGRAPHY_IMP_ERR = None +CRYPTOGRAPHY_VERSION = None +try: + import cryptography + from cryptography import x509 + CRYPTOGRAPHY_VERSION = LooseVersion(cryptography.__version__) +except ImportError: + CRYPTOGRAPHY_IMP_ERR = traceback.format_exc() + CRYPTOGRAPHY_FOUND = False +else: + CRYPTOGRAPHY_FOUND = True + + +class CertificateError(OpenSSLObjectError): + pass + + +@six.add_metaclass(abc.ABCMeta) +class CertificateBackend(object): + def __init__(self, module, backend): + self.module = module + self.backend = backend + + self.force = module.params['force'] + self.ignore_timestamps = module.params['ignore_timestamps'] + self.privatekey_path = module.params['privatekey_path'] + self.privatekey_content = module.params['privatekey_content'] + if self.privatekey_content is not None: + self.privatekey_content = self.privatekey_content.encode('utf-8') + self.privatekey_passphrase = module.params['privatekey_passphrase'] + self.csr_path = module.params['csr_path'] + self.csr_content = module.params['csr_content'] + if self.csr_content is not None: + self.csr_content = self.csr_content.encode('utf-8') + + # The following are default values which make sure check() works as + # before if providers do not explicitly change these properties. + self.create_subject_key_identifier = 'never_create' + self.create_authority_key_identifier = False + + self.privatekey = None + self.csr = None + self.cert = None + self.existing_certificate = None + self.existing_certificate_bytes = None + + self.check_csr_subject = True + self.check_csr_extensions = True + + self.diff_before = self._get_info(None) + self.diff_after = self._get_info(None) + + def _get_info(self, data): + if data is None: + return dict() + try: + result = get_certificate_info(self.module, self.backend, data, prefer_one_fingerprint=True) + result['can_parse_certificate'] = True + return result + except Exception as exc: + return dict(can_parse_certificate=False) + + @abc.abstractmethod + def generate_certificate(self): + """(Re-)Generate certificate.""" + pass + + @abc.abstractmethod + def get_certificate_data(self): + """Return bytes for self.cert.""" + pass + + def set_existing(self, certificate_bytes): + """Set existing certificate bytes. None indicates that the key does not exist.""" + self.existing_certificate_bytes = certificate_bytes + self.diff_after = self.diff_before = self._get_info(self.existing_certificate_bytes) + + def has_existing(self): + """Query whether an existing certificate is/has been there.""" + return self.existing_certificate_bytes is not None + + def _ensure_private_key_loaded(self): + """Load the provided private key into self.privatekey.""" + if self.privatekey is not None: + return + if self.privatekey_path is None and self.privatekey_content is None: + return + try: + self.privatekey = load_privatekey( + path=self.privatekey_path, + content=self.privatekey_content, + passphrase=self.privatekey_passphrase, + backend=self.backend, + ) + except OpenSSLBadPassphraseError as exc: + raise CertificateError(exc) + + def _ensure_csr_loaded(self): + """Load the CSR into self.csr.""" + if self.csr is not None: + return + if self.csr_path is None and self.csr_content is None: + return + self.csr = load_certificate_request( + path=self.csr_path, + content=self.csr_content, + backend=self.backend, + ) + + def _ensure_existing_certificate_loaded(self): + """Load the existing certificate into self.existing_certificate.""" + if self.existing_certificate is not None: + return + if self.existing_certificate_bytes is None: + return + self.existing_certificate = load_certificate( + path=None, + content=self.existing_certificate_bytes, + backend=self.backend, + ) + + def _check_privatekey(self): + """Check whether provided parameters match, assuming self.existing_certificate and self.privatekey have been populated.""" + if self.backend == 'cryptography': + return cryptography_compare_public_keys(self.existing_certificate.public_key(), self.privatekey.public_key()) + + def _check_csr(self): + """Check whether provided parameters match, assuming self.existing_certificate and self.csr have been populated.""" + if self.backend == 'cryptography': + # Verify that CSR is signed by certificate's private key + if not self.csr.is_signature_valid: + return False + if not cryptography_compare_public_keys(self.csr.public_key(), self.existing_certificate.public_key()): + return False + # Check subject + if self.check_csr_subject and self.csr.subject != self.existing_certificate.subject: + return False + # Check extensions + if not self.check_csr_extensions: + return True + cert_exts = list(self.existing_certificate.extensions) + csr_exts = list(self.csr.extensions) + if self.create_subject_key_identifier != 'never_create': + # Filter out SubjectKeyIdentifier extension before comparison + cert_exts = list(filter(lambda x: not isinstance(x.value, x509.SubjectKeyIdentifier), cert_exts)) + csr_exts = list(filter(lambda x: not isinstance(x.value, x509.SubjectKeyIdentifier), csr_exts)) + if self.create_authority_key_identifier: + # Filter out AuthorityKeyIdentifier extension before comparison + cert_exts = list(filter(lambda x: not isinstance(x.value, x509.AuthorityKeyIdentifier), cert_exts)) + csr_exts = list(filter(lambda x: not isinstance(x.value, x509.AuthorityKeyIdentifier), csr_exts)) + if len(cert_exts) != len(csr_exts): + return False + for cert_ext in cert_exts: + try: + csr_ext = self.csr.extensions.get_extension_for_oid(cert_ext.oid) + if cert_ext != csr_ext: + return False + except cryptography.x509.ExtensionNotFound as dummy: + return False + return True + + def _check_subject_key_identifier(self): + """Check whether Subject Key Identifier matches, assuming self.existing_certificate has been populated.""" + # Get hold of certificate's SKI + try: + ext = self.existing_certificate.extensions.get_extension_for_class(x509.SubjectKeyIdentifier) + except cryptography.x509.ExtensionNotFound as dummy: + return False + # Get hold of CSR's SKI for 'create_if_not_provided' + csr_ext = None + if self.create_subject_key_identifier == 'create_if_not_provided': + try: + csr_ext = self.csr.extensions.get_extension_for_class(x509.SubjectKeyIdentifier) + except cryptography.x509.ExtensionNotFound as dummy: + pass + if csr_ext is None: + # If CSR had no SKI, or we chose to ignore it ('always_create'), compare with created SKI + if ext.value.digest != x509.SubjectKeyIdentifier.from_public_key(self.existing_certificate.public_key()).digest: + return False + else: + # If CSR had SKI and we did not ignore it ('create_if_not_provided'), compare SKIs + if ext.value.digest != csr_ext.value.digest: + return False + return True + + def needs_regeneration(self, not_before=None, not_after=None): + """Check whether a regeneration is necessary.""" + if self.force or self.existing_certificate_bytes is None: + return True + + try: + self._ensure_existing_certificate_loaded() + except Exception as dummy: + return True + + # Check whether private key matches + self._ensure_private_key_loaded() + if self.privatekey is not None and not self._check_privatekey(): + return True + + # Check whether CSR matches + self._ensure_csr_loaded() + if self.csr is not None and not self._check_csr(): + return True + + # Check SubjectKeyIdentifier + if self.create_subject_key_identifier != 'never_create' and not self._check_subject_key_identifier(): + return True + + # Check not before + if not_before is not None and not self.ignore_timestamps: + if self.existing_certificate.not_valid_before != not_before: + return True + + # Check not after + if not_after is not None and not self.ignore_timestamps: + if self.existing_certificate.not_valid_after != not_after: + return True + return False + + def dump(self, include_certificate): + """Serialize the object into a dictionary.""" + result = { + 'privatekey': self.privatekey_path, + 'csr': self.csr_path + } + # Get hold of certificate bytes + certificate_bytes = self.existing_certificate_bytes + if self.cert is not None: + certificate_bytes = self.get_certificate_data() + self.diff_after = self._get_info(certificate_bytes) + if include_certificate: + # Store result + result['certificate'] = certificate_bytes.decode('utf-8') if certificate_bytes else None + + result['diff'] = dict( + before=self.diff_before, + after=self.diff_after, + ) + return result + + +@six.add_metaclass(abc.ABCMeta) +class CertificateProvider(object): + @abc.abstractmethod + def validate_module_args(self, module): + """Check module arguments""" + + @abc.abstractmethod + def needs_version_two_certs(self, module): + """Whether the provider needs to create a version 2 certificate.""" + + @abc.abstractmethod + def create_backend(self, module, backend): + """Create an implementation for a backend. + + Return value must be instance of CertificateBackend. + """ + + +def select_backend(module, backend, provider): + """ + :type module: AnsibleModule + :type backend: str + :type provider: CertificateProvider + """ + provider.validate_module_args(module) + + backend = module.params['select_crypto_backend'] + if backend == 'auto': + # Detect what backend we can use + can_use_cryptography = CRYPTOGRAPHY_FOUND and CRYPTOGRAPHY_VERSION >= LooseVersion(MINIMAL_CRYPTOGRAPHY_VERSION) + + # If cryptography is available we'll use it + if can_use_cryptography: + backend = 'cryptography' + + # Fail if no backend has been found + if backend == 'auto': + module.fail_json(msg=("Cannot detect the required Python library " + "cryptography (>= {0})").format(MINIMAL_CRYPTOGRAPHY_VERSION)) + + if backend == 'cryptography': + if not CRYPTOGRAPHY_FOUND: + module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)), + exception=CRYPTOGRAPHY_IMP_ERR) + if provider.needs_version_two_certs(module): + module.fail_json(msg='The cryptography backend does not support v2 certificates') + + return provider.create_backend(module, backend) + + +def get_certificate_argument_spec(): + return ArgumentSpec( + argument_spec=dict( + provider=dict(type='str', choices=[]), # choices will be filled by add_XXX_provider_to_argument_spec() in certificate_xxx.py + force=dict(type='bool', default=False,), + csr_path=dict(type='path'), + csr_content=dict(type='str'), + ignore_timestamps=dict(type='bool', default=True), + select_crypto_backend=dict(type='str', default='auto', choices=['auto', 'cryptography']), + + # General properties of a certificate + privatekey_path=dict(type='path'), + privatekey_content=dict(type='str', no_log=True), + privatekey_passphrase=dict(type='str', no_log=True), + ), + mutually_exclusive=[ + ['csr_path', 'csr_content'], + ['privatekey_path', 'privatekey_content'], + ], + ) diff --git a/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/certificate_acme.py b/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/certificate_acme.py new file mode 100644 index 00000000..18f30db5 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/certificate_acme.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2016-2017, Yanis Guenane <yanis+ansible@guenane.org> +# Copyright (c) 2017, Markus Teufelberger <mteufelberger+ansible@mgit.at> +# 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 os +import tempfile +import traceback + +from ansible.module_utils.common.text.converters import to_native, to_bytes + +from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.certificate import ( + CertificateError, + CertificateBackend, + CertificateProvider, +) + + +class AcmeCertificateBackend(CertificateBackend): + def __init__(self, module, backend): + super(AcmeCertificateBackend, self).__init__(module, backend) + self.accountkey_path = module.params['acme_accountkey_path'] + self.challenge_path = module.params['acme_challenge_path'] + self.use_chain = module.params['acme_chain'] + self.acme_directory = module.params['acme_directory'] + + if self.csr_content is None and self.csr_path is None: + raise CertificateError( + 'csr_path or csr_content is required for ownca provider' + ) + if self.csr_content is None and not os.path.exists(self.csr_path): + raise CertificateError( + 'The certificate signing request file %s does not exist' % self.csr_path + ) + + if not os.path.exists(self.accountkey_path): + raise CertificateError( + 'The account key %s does not exist' % self.accountkey_path + ) + + if not os.path.exists(self.challenge_path): + raise CertificateError( + 'The challenge path %s does not exist' % self.challenge_path + ) + + self.acme_tiny_path = self.module.get_bin_path('acme-tiny', required=True) + + def generate_certificate(self): + """(Re-)Generate certificate.""" + + command = [self.acme_tiny_path] + if self.use_chain: + command.append('--chain') + command.extend(['--account-key', self.accountkey_path]) + if self.csr_content is not None: + # We need to temporarily write the CSR to disk + fd, tmpsrc = tempfile.mkstemp() + self.module.add_cleanup_file(tmpsrc) # Ansible will delete the file on exit + f = os.fdopen(fd, 'wb') + try: + f.write(self.csr_content) + except Exception as err: + try: + f.close() + except Exception as dummy: + pass + self.module.fail_json( + msg="failed to create temporary CSR file: %s" % to_native(err), + exception=traceback.format_exc() + ) + f.close() + command.extend(['--csr', tmpsrc]) + else: + command.extend(['--csr', self.csr_path]) + command.extend(['--acme-dir', self.challenge_path]) + command.extend(['--directory-url', self.acme_directory]) + + try: + self.cert = to_bytes(self.module.run_command(command, check_rc=True)[1]) + except OSError as exc: + raise CertificateError(exc) + + def get_certificate_data(self): + """Return bytes for self.cert.""" + return self.cert + + def dump(self, include_certificate): + result = super(AcmeCertificateBackend, self).dump(include_certificate) + result['accountkey'] = self.accountkey_path + return result + + +class AcmeCertificateProvider(CertificateProvider): + def validate_module_args(self, module): + if module.params['acme_accountkey_path'] is None: + module.fail_json(msg='The acme_accountkey_path option must be specified for the acme provider.') + if module.params['acme_challenge_path'] is None: + module.fail_json(msg='The acme_challenge_path option must be specified for the acme provider.') + + def needs_version_two_certs(self, module): + return False + + def create_backend(self, module, backend): + return AcmeCertificateBackend(module, backend) + + +def add_acme_provider_to_argument_spec(argument_spec): + argument_spec.argument_spec['provider']['choices'].append('acme') + argument_spec.argument_spec.update(dict( + acme_accountkey_path=dict(type='path'), + acme_challenge_path=dict(type='path'), + acme_chain=dict(type='bool', default=False), + acme_directory=dict(type='str', default="https://acme-v02.api.letsencrypt.org/directory"), + )) 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 new file mode 100644 index 00000000..baf53f5d --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/certificate_entrust.py @@ -0,0 +1,211 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2016-2017, Yanis Guenane <yanis+ansible@guenane.org> +# Copyright (c) 2017, Markus Teufelberger <mteufelberger+ansible@mgit.at> +# 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 time +import os + +from ansible.module_utils.common.text.converters import to_native, to_bytes + +from ansible_collections.community.crypto.plugins.module_utils.ecs.api import ECSClient, RestOperationException, SessionConfigurationException + +from ansible_collections.community.crypto.plugins.module_utils.crypto.support import ( + load_certificate, + get_relative_time_option, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_support import ( + cryptography_serial_number_of_cert, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.certificate import ( + CertificateError, + CertificateBackend, + CertificateProvider, +) + +try: + from cryptography.x509.oid import NameOID +except ImportError: + pass + + +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) + + if self.csr_content is None and self.csr_path is None: + raise CertificateError( + 'csr_path or csr_content is required for entrust provider' + ) + if self.csr_content is None and not os.path.exists(self.csr_path): + raise CertificateError( + 'The certificate signing request file {0} does not exist'.format(self.csr_path) + ) + + self._ensure_csr_loaded() + + # ECS API defaults to using the validated organization tied to the account. + # We want to always force behavior of trying to use the organization provided in the CSR. + # To that end we need to parse out the organization from the CSR. + self.csr_org = None + if self.backend == 'cryptography': + csr_subject_orgs = self.csr.subject.get_attributes_for_oid(NameOID.ORGANIZATION_NAME) + if len(csr_subject_orgs) == 1: + self.csr_org = csr_subject_orgs[0].value + elif len(csr_subject_orgs) > 1: + self.module.fail_json(msg=("Entrust provider does not currently support multiple validated organizations. Multiple organizations found in " + "Subject DN: '{0}'. ".format(self.csr.subject))) + # If no organization in the CSR, explicitly tell ECS that it should be blank in issued cert, not defaulted to + # organization tied to the account. + if self.csr_org is None: + self.csr_org = '' + + try: + self.ecs_client = ECSClient( + entrust_api_user=self.module.params['entrust_api_user'], + entrust_api_key=self.module.params['entrust_api_key'], + entrust_api_cert=self.module.params['entrust_api_client_cert_path'], + entrust_api_cert_key=self.module.params['entrust_api_client_cert_key_path'], + entrust_api_specification_path=self.module.params['entrust_api_specification_path'] + ) + except SessionConfigurationException as e: + module.fail_json(msg='Failed to initialize Entrust Provider: {0}'.format(to_native(e.message))) + + def generate_certificate(self): + """(Re-)Generate certificate.""" + body = {} + + # Read the CSR that was generated for us + if self.csr_content is not None: + # csr_content contains bytes + body['csr'] = to_native(self.csr_content) + else: + with open(self.csr_path, 'r') as csr_file: + body['csr'] = csr_file.read() + + body['certType'] = self.module.params['entrust_cert_type'] + + # Handle expiration (30 days if not specified) + expiry = self.notAfter + if not expiry: + gmt_now = datetime.datetime.fromtimestamp(time.mktime(time.gmtime())) + expiry = gmt_now + datetime.timedelta(days=365) + + expiry_iso3339 = expiry.strftime("%Y-%m-%dT%H:%M:%S.00Z") + body['certExpiryDate'] = expiry_iso3339 + body['org'] = self.csr_org + body['tracking'] = { + 'requesterName': self.module.params['entrust_requester_name'], + 'requesterEmail': self.module.params['entrust_requester_email'], + 'requesterPhone': self.module.params['entrust_requester_phone'], + } + + try: + result = self.ecs_client.NewCertRequest(Body=body) + self.trackingId = result.get('trackingId') + except RestOperationException as e: + self.module.fail_json(msg='Failed to request new certificate from Entrust Certificate Services (ECS): {0}'.format(to_native(e.message))) + + self.cert_bytes = to_bytes(result.get('endEntityCert')) + self.cert = load_certificate(path=None, content=self.cert_bytes, backend=self.backend) + + def get_certificate_data(self): + """Return bytes for self.cert.""" + return self.cert_bytes + + def needs_regeneration(self): + parent_check = super(EntrustCertificateBackend, self).needs_regeneration() + + try: + cert_details = self._get_cert_details() + except RestOperationException as e: + self.module.fail_json(msg='Failed to get status of existing certificate from Entrust Certificate Services (ECS): {0}.'.format(to_native(e.message))) + + # Always issue a new certificate if the certificate is expired, suspended or revoked + status = cert_details.get('status', False) + if status == 'EXPIRED' or status == 'SUSPENDED' or status == 'REVOKED': + return True + + # If the requested cert type was specified and it is for a different certificate type than the initial certificate, a new one is needed + if self.module.params['entrust_cert_type'] and cert_details.get('certType') and self.module.params['entrust_cert_type'] != cert_details.get('certType'): + return True + + return parent_check + + def _get_cert_details(self): + cert_details = {} + try: + self._ensure_existing_certificate_loaded() + except Exception as dummy: + return + if self.existing_certificate: + serial_number = None + expiry = None + if self.backend == 'cryptography': + serial_number = "{0:X}".format(cryptography_serial_number_of_cert(self.existing_certificate)) + expiry = self.existing_certificate.not_valid_after + + # get some information about the expiry of this certificate + expiry_iso3339 = expiry.strftime("%Y-%m-%dT%H:%M:%S.00Z") + cert_details['expiresAfter'] = expiry_iso3339 + + # If a trackingId is not already defined (from the result of a generate) + # use the serial number to identify the tracking Id + if self.trackingId is None and serial_number is not None: + cert_results = self.ecs_client.GetCertificates(serialNumber=serial_number).get('certificates', {}) + + # Finding 0 or more than 1 result is a very unlikely use case, it simply means we cannot perform additional checks + # on the 'state' as returned by Entrust Certificate Services (ECS). The general certificate validity is + # still checked as it is in the rest of the module. + if len(cert_results) == 1: + self.trackingId = cert_results[0].get('trackingId') + + if self.trackingId is not None: + cert_details.update(self.ecs_client.GetCertificate(trackingId=self.trackingId)) + + return cert_details + + +class EntrustCertificateProvider(CertificateProvider): + def validate_module_args(self, module): + pass + + def needs_version_two_certs(self, module): + return False + + def create_backend(self, module, backend): + return EntrustCertificateBackend(module, backend) + + +def add_entrust_provider_to_argument_spec(argument_spec): + argument_spec.argument_spec['provider']['choices'].append('entrust') + argument_spec.argument_spec.update(dict( + entrust_cert_type=dict(type='str', default='STANDARD_SSL', + choices=['STANDARD_SSL', 'ADVANTAGE_SSL', 'UC_SSL', 'EV_SSL', 'WILDCARD_SSL', + 'PRIVATE_SSL', 'PD_SSL', 'CDS_ENT_LITE', 'CDS_ENT_PRO', 'SMIME_ENT']), + entrust_requester_email=dict(type='str'), + entrust_requester_name=dict(type='str'), + entrust_requester_phone=dict(type='str'), + entrust_api_user=dict(type='str'), + entrust_api_key=dict(type='str', no_log=True), + entrust_api_client_cert_path=dict(type='path'), + entrust_api_client_cert_key_path=dict(type='path', no_log=True), + entrust_api_specification_path=dict(type='path', default='https://cloud.entrust.net/EntrustCloud/documentation/cms-api-2.1.0.yaml'), + entrust_not_after=dict(type='str', default='+365d'), + )) + argument_spec.required_if.append( + ['provider', 'entrust', ['entrust_requester_email', 'entrust_requester_name', 'entrust_requester_phone', + 'entrust_api_user', 'entrust_api_key', 'entrust_api_client_cert_path', + 'entrust_api_client_cert_key_path']] + ) 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 new file mode 100644 index 00000000..a7beec6c --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/certificate_info.py @@ -0,0 +1,411 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2016-2017, Yanis Guenane <yanis+ansible@guenane.org> +# Copyright (c) 2017, Markus Teufelberger <mteufelberger+ansible@mgit.at> +# 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 + + +import abc +import binascii +import datetime +import traceback + +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 + +from ansible_collections.community.crypto.plugins.module_utils.version import LooseVersion + +from ansible_collections.community.crypto.plugins.module_utils.crypto.support import ( + load_certificate, + get_fingerprint_of_bytes, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_support import ( + cryptography_decode_name, + cryptography_get_extensions_from_cert, + cryptography_oid_to_name, + cryptography_serial_number_of_cert, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.publickey_info import ( + get_publickey_info, +) + +MINIMAL_CRYPTOGRAPHY_VERSION = '1.6' + +CRYPTOGRAPHY_IMP_ERR = None +try: + import cryptography + from cryptography import x509 + from cryptography.hazmat.primitives import serialization + CRYPTOGRAPHY_VERSION = LooseVersion(cryptography.__version__) +except ImportError: + CRYPTOGRAPHY_IMP_ERR = traceback.format_exc() + CRYPTOGRAPHY_FOUND = False +else: + CRYPTOGRAPHY_FOUND = True + + +TIMESTAMP_FORMAT = "%Y%m%d%H%M%SZ" + + +@six.add_metaclass(abc.ABCMeta) +class CertificateInfoRetrieval(object): + def __init__(self, module, backend, content): + # content must be a bytes string + self.module = module + self.backend = backend + self.content = content + + @abc.abstractmethod + def _get_der_bytes(self): + pass + + @abc.abstractmethod + def _get_signature_algorithm(self): + pass + + @abc.abstractmethod + def _get_subject_ordered(self): + pass + + @abc.abstractmethod + def _get_issuer_ordered(self): + pass + + @abc.abstractmethod + def _get_version(self): + pass + + @abc.abstractmethod + def _get_key_usage(self): + pass + + @abc.abstractmethod + def _get_extended_key_usage(self): + pass + + @abc.abstractmethod + def _get_basic_constraints(self): + pass + + @abc.abstractmethod + def _get_ocsp_must_staple(self): + pass + + @abc.abstractmethod + def _get_subject_alt_name(self): + pass + + @abc.abstractmethod + def get_not_before(self): + pass + + @abc.abstractmethod + def get_not_after(self): + pass + + @abc.abstractmethod + def _get_public_key_pem(self): + pass + + @abc.abstractmethod + def _get_public_key_object(self): + pass + + @abc.abstractmethod + def _get_subject_key_identifier(self): + pass + + @abc.abstractmethod + def _get_authority_key_identifier(self): + pass + + @abc.abstractmethod + def _get_serial_number(self): + pass + + @abc.abstractmethod + def _get_all_extensions(self): + pass + + @abc.abstractmethod + def _get_ocsp_uri(self): + pass + + @abc.abstractmethod + def _get_issuer_uri(self): + pass + + def get_info(self, prefer_one_fingerprint=False): + result = dict() + self.cert = load_certificate(None, content=self.content, backend=self.backend) + + result['signature_algorithm'] = self._get_signature_algorithm() + subject = self._get_subject_ordered() + issuer = self._get_issuer_ordered() + result['subject'] = dict() + for k, v in subject: + result['subject'][k] = v + result['subject_ordered'] = subject + result['issuer'] = dict() + for k, v in issuer: + result['issuer'][k] = v + result['issuer_ordered'] = issuer + result['version'] = self._get_version() + result['key_usage'], result['key_usage_critical'] = self._get_key_usage() + result['extended_key_usage'], result['extended_key_usage_critical'] = self._get_extended_key_usage() + result['basic_constraints'], result['basic_constraints_critical'] = self._get_basic_constraints() + result['ocsp_must_staple'], result['ocsp_must_staple_critical'] = self._get_ocsp_must_staple() + result['subject_alt_name'], result['subject_alt_name_critical'] = self._get_subject_alt_name() + + not_before = self.get_not_before() + not_after = self.get_not_after() + result['not_before'] = not_before.strftime(TIMESTAMP_FORMAT) + result['not_after'] = not_after.strftime(TIMESTAMP_FORMAT) + result['expired'] = not_after < datetime.datetime.utcnow() + + result['public_key'] = to_native(self._get_public_key_pem()) + + public_key_info = get_publickey_info( + self.module, + self.backend, + key=self._get_public_key_object(), + prefer_one_fingerprint=prefer_one_fingerprint) + result.update({ + 'public_key_type': public_key_info['type'], + 'public_key_data': public_key_info['public_data'], + 'public_key_fingerprints': public_key_info['fingerprints'], + }) + + result['fingerprints'] = get_fingerprint_of_bytes( + self._get_der_bytes(), prefer_one=prefer_one_fingerprint) + + ski = self._get_subject_key_identifier() + if ski is not None: + ski = to_native(binascii.hexlify(ski)) + ski = ':'.join([ski[i:i + 2] for i in range(0, len(ski), 2)]) + result['subject_key_identifier'] = ski + + aki, aci, acsn = self._get_authority_key_identifier() + if aki is not None: + aki = to_native(binascii.hexlify(aki)) + aki = ':'.join([aki[i:i + 2] for i in range(0, len(aki), 2)]) + result['authority_key_identifier'] = aki + result['authority_cert_issuer'] = aci + result['authority_cert_serial_number'] = acsn + + result['serial_number'] = self._get_serial_number() + result['extensions_by_oid'] = self._get_all_extensions() + result['ocsp_uri'] = self._get_ocsp_uri() + result['issuer_uri'] = self._get_issuer_uri() + + return result + + +class CertificateInfoRetrievalCryptography(CertificateInfoRetrieval): + """Validate the supplied cert, using the cryptography backend""" + def __init__(self, module, content): + super(CertificateInfoRetrievalCryptography, self).__init__(module, 'cryptography', content) + self.name_encoding = module.params.get('name_encoding', 'ignore') + + def _get_der_bytes(self): + return self.cert.public_bytes(serialization.Encoding.DER) + + def _get_signature_algorithm(self): + return cryptography_oid_to_name(self.cert.signature_algorithm_oid) + + def _get_subject_ordered(self): + result = [] + for attribute in self.cert.subject: + result.append([cryptography_oid_to_name(attribute.oid), attribute.value]) + return result + + def _get_issuer_ordered(self): + result = [] + for attribute in self.cert.issuer: + result.append([cryptography_oid_to_name(attribute.oid), attribute.value]) + return result + + def _get_version(self): + if self.cert.version == x509.Version.v1: + return 1 + if self.cert.version == x509.Version.v3: + return 3 + return "unknown" + + def _get_key_usage(self): + try: + current_key_ext = self.cert.extensions.get_extension_for_class(x509.KeyUsage) + current_key_usage = current_key_ext.value + key_usage = dict( + digital_signature=current_key_usage.digital_signature, + content_commitment=current_key_usage.content_commitment, + key_encipherment=current_key_usage.key_encipherment, + data_encipherment=current_key_usage.data_encipherment, + key_agreement=current_key_usage.key_agreement, + key_cert_sign=current_key_usage.key_cert_sign, + crl_sign=current_key_usage.crl_sign, + encipher_only=False, + decipher_only=False, + ) + if key_usage['key_agreement']: + key_usage.update(dict( + encipher_only=current_key_usage.encipher_only, + decipher_only=current_key_usage.decipher_only + )) + + key_usage_names = dict( + digital_signature='Digital Signature', + content_commitment='Non Repudiation', + key_encipherment='Key Encipherment', + data_encipherment='Data Encipherment', + key_agreement='Key Agreement', + key_cert_sign='Certificate Sign', + crl_sign='CRL Sign', + encipher_only='Encipher Only', + decipher_only='Decipher Only', + ) + return sorted([ + key_usage_names[name] for name, value in key_usage.items() if value + ]), current_key_ext.critical + except cryptography.x509.ExtensionNotFound: + return None, False + + def _get_extended_key_usage(self): + try: + ext_keyusage_ext = self.cert.extensions.get_extension_for_class(x509.ExtendedKeyUsage) + return sorted([ + cryptography_oid_to_name(eku) for eku in ext_keyusage_ext.value + ]), ext_keyusage_ext.critical + except cryptography.x509.ExtensionNotFound: + return None, False + + def _get_basic_constraints(self): + try: + ext_keyusage_ext = self.cert.extensions.get_extension_for_class(x509.BasicConstraints) + result = [] + result.append('CA:{0}'.format('TRUE' if ext_keyusage_ext.value.ca else 'FALSE')) + if ext_keyusage_ext.value.path_length is not None: + result.append('pathlen:{0}'.format(ext_keyusage_ext.value.path_length)) + return sorted(result), ext_keyusage_ext.critical + except cryptography.x509.ExtensionNotFound: + return None, False + + def _get_ocsp_must_staple(self): + try: + try: + # This only works with cryptography >= 2.1 + tlsfeature_ext = self.cert.extensions.get_extension_for_class(x509.TLSFeature) + value = cryptography.x509.TLSFeatureType.status_request in tlsfeature_ext.value + except AttributeError: + # Fallback for cryptography < 2.1 + oid = x509.oid.ObjectIdentifier("1.3.6.1.5.5.7.1.24") + tlsfeature_ext = self.cert.extensions.get_extension_for_oid(oid) + value = tlsfeature_ext.value.value == b"\x30\x03\x02\x01\x05" + return value, tlsfeature_ext.critical + except cryptography.x509.ExtensionNotFound: + return None, False + + def _get_subject_alt_name(self): + try: + san_ext = self.cert.extensions.get_extension_for_class(x509.SubjectAlternativeName) + result = [cryptography_decode_name(san, idn_rewrite=self.name_encoding) for san in san_ext.value] + return result, san_ext.critical + except cryptography.x509.ExtensionNotFound: + return None, False + + def get_not_before(self): + return self.cert.not_valid_before + + def get_not_after(self): + return self.cert.not_valid_after + + def _get_public_key_pem(self): + return self.cert.public_key().public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo, + ) + + def _get_public_key_object(self): + return self.cert.public_key() + + def _get_subject_key_identifier(self): + try: + ext = self.cert.extensions.get_extension_for_class(x509.SubjectKeyIdentifier) + return ext.value.digest + except cryptography.x509.ExtensionNotFound: + return None + + def _get_authority_key_identifier(self): + try: + ext = self.cert.extensions.get_extension_for_class(x509.AuthorityKeyIdentifier) + issuer = None + if ext.value.authority_cert_issuer is not None: + issuer = [cryptography_decode_name(san, idn_rewrite=self.name_encoding) for san in ext.value.authority_cert_issuer] + return ext.value.key_identifier, issuer, ext.value.authority_cert_serial_number + except cryptography.x509.ExtensionNotFound: + return None, None, None + + def _get_serial_number(self): + return cryptography_serial_number_of_cert(self.cert) + + def _get_all_extensions(self): + return cryptography_get_extensions_from_cert(self.cert) + + def _get_ocsp_uri(self): + try: + ext = self.cert.extensions.get_extension_for_class(x509.AuthorityInformationAccess) + for desc in ext.value: + if desc.access_method == x509.oid.AuthorityInformationAccessOID.OCSP: + if isinstance(desc.access_location, x509.UniformResourceIdentifier): + return desc.access_location.value + except x509.ExtensionNotFound as dummy: + pass + return None + + def _get_issuer_uri(self): + try: + ext = self.cert.extensions.get_extension_for_class(x509.AuthorityInformationAccess) + for desc in ext.value: + if desc.access_method == x509.oid.AuthorityInformationAccessOID.CA_ISSUERS: + if isinstance(desc.access_location, x509.UniformResourceIdentifier): + return desc.access_location.value + except x509.ExtensionNotFound as dummy: + pass + return None + + +def get_certificate_info(module, backend, content, prefer_one_fingerprint=False): + if backend == 'cryptography': + info = CertificateInfoRetrievalCryptography(module, content) + return info.get_info(prefer_one_fingerprint=prefer_one_fingerprint) + + +def select_backend(module, backend, content): + if backend == 'auto': + # Detection what is possible + can_use_cryptography = CRYPTOGRAPHY_FOUND and CRYPTOGRAPHY_VERSION >= LooseVersion(MINIMAL_CRYPTOGRAPHY_VERSION) + + # Try cryptography + if can_use_cryptography: + backend = 'cryptography' + + # Success? + if backend == 'auto': + module.fail_json(msg=("Cannot detect any of the required Python libraries " + "cryptography (>= {0})").format(MINIMAL_CRYPTOGRAPHY_VERSION)) + + if backend == 'cryptography': + if not CRYPTOGRAPHY_FOUND: + module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)), + exception=CRYPTOGRAPHY_IMP_ERR) + return backend, CertificateInfoRetrievalCryptography(module, content) + else: + raise ValueError('Unsupported value for backend: {0}'.format(backend)) 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 new file mode 100644 index 00000000..ac1cf845 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/certificate_ownca.py @@ -0,0 +1,276 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2016-2017, Yanis Guenane <yanis+ansible@guenane.org> +# Copyright (c) 2017, Markus Teufelberger <mteufelberger+ansible@mgit.at> +# 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 os + +from random import randrange + +from ansible_collections.community.crypto.plugins.module_utils.version import LooseVersion + +from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import ( + OpenSSLBadPassphraseError, +) + +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_compare_public_keys, + cryptography_key_needs_digest_for_signing, + cryptography_serial_number_of_cert, + cryptography_verify_certificate_signature, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.certificate import ( + CRYPTOGRAPHY_VERSION, + CertificateError, + CertificateBackend, + CertificateProvider, +) + +try: + import cryptography + from cryptography import x509 + from cryptography.hazmat.backends import default_backend + from cryptography.hazmat.primitives.serialization import Encoding +except ImportError: + pass + + +class OwnCACertificateBackendCryptography(CertificateBackend): + def __init__(self, module): + super(OwnCACertificateBackendCryptography, self).__init__(module, 'cryptography') + + 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.digest = select_message_digest(module.params['ownca_digest']) + self.version = module.params['ownca_version'] + self.serial_number = x509.random_serial_number() + self.ca_cert_path = module.params['ownca_path'] + self.ca_cert_content = module.params['ownca_content'] + if self.ca_cert_content is not None: + self.ca_cert_content = self.ca_cert_content.encode('utf-8') + self.ca_privatekey_path = module.params['ownca_privatekey_path'] + self.ca_privatekey_content = module.params['ownca_privatekey_content'] + if self.ca_privatekey_content is not None: + self.ca_privatekey_content = self.ca_privatekey_content.encode('utf-8') + self.ca_privatekey_passphrase = module.params['ownca_privatekey_passphrase'] + + if self.csr_content is None and self.csr_path is None: + raise CertificateError( + 'csr_path or csr_content is required for ownca provider' + ) + if self.csr_content is None and not os.path.exists(self.csr_path): + raise CertificateError( + 'The certificate signing request file {0} does not exist'.format(self.csr_path) + ) + if self.ca_cert_content is None and not os.path.exists(self.ca_cert_path): + raise CertificateError( + 'The CA certificate file {0} does not exist'.format(self.ca_cert_path) + ) + if self.ca_privatekey_content is None and not os.path.exists(self.ca_privatekey_path): + raise CertificateError( + 'The CA private key file {0} does not exist'.format(self.ca_privatekey_path) + ) + + self._ensure_csr_loaded() + self.ca_cert = load_certificate( + path=self.ca_cert_path, + content=self.ca_cert_content, + backend=self.backend + ) + try: + self.ca_private_key = load_privatekey( + path=self.ca_privatekey_path, + content=self.ca_privatekey_content, + passphrase=self.ca_privatekey_passphrase, + backend=self.backend + ) + except OpenSSLBadPassphraseError as exc: + module.fail_json(msg=str(exc)) + + if not cryptography_compare_public_keys(self.ca_cert.public_key(), self.ca_private_key.public_key()): + raise CertificateError('The CA private key does not belong to the CA certificate') + + if cryptography_key_needs_digest_for_signing(self.ca_private_key): + if self.digest is None: + raise CertificateError( + 'The digest %s is not supported with the cryptography backend' % module.params['ownca_digest'] + ) + else: + self.digest = None + + def generate_certificate(self): + """(Re-)Generate certificate.""" + cert_builder = x509.CertificateBuilder() + cert_builder = cert_builder.subject_name(self.csr.subject) + cert_builder = cert_builder.issuer_name(self.ca_cert.subject) + cert_builder = cert_builder.serial_number(self.serial_number) + cert_builder = cert_builder.not_valid_before(self.notBefore) + cert_builder = cert_builder.not_valid_after(self.notAfter) + cert_builder = cert_builder.public_key(self.csr.public_key()) + has_ski = False + for extension in self.csr.extensions: + if isinstance(extension.value, x509.SubjectKeyIdentifier): + if self.create_subject_key_identifier == 'always_create': + continue + has_ski = True + if self.create_authority_key_identifier and isinstance(extension.value, x509.AuthorityKeyIdentifier): + continue + cert_builder = cert_builder.add_extension(extension.value, critical=extension.critical) + if not has_ski and self.create_subject_key_identifier != 'never_create': + cert_builder = cert_builder.add_extension( + x509.SubjectKeyIdentifier.from_public_key(self.csr.public_key()), + critical=False + ) + if self.create_authority_key_identifier: + try: + ext = self.ca_cert.extensions.get_extension_for_class(x509.SubjectKeyIdentifier) + cert_builder = cert_builder.add_extension( + x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(ext.value) + if CRYPTOGRAPHY_VERSION >= LooseVersion('2.7') else + x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(ext), + critical=False + ) + except cryptography.x509.ExtensionNotFound: + cert_builder = cert_builder.add_extension( + x509.AuthorityKeyIdentifier.from_issuer_public_key(self.ca_cert.public_key()), + critical=False + ) + + try: + certificate = cert_builder.sign( + private_key=self.ca_private_key, algorithm=self.digest, + backend=default_backend() + ) + except TypeError as e: + if str(e) == 'Algorithm must be a registered hash algorithm.' and self.digest is None: + self.module.fail_json(msg='Signing with Ed25519 and Ed448 keys requires cryptography 2.8 or newer.') + raise + + self.cert = certificate + + def get_certificate_data(self): + """Return bytes for self.cert.""" + return self.cert.public_bytes(Encoding.PEM) + + def needs_regeneration(self): + if super(OwnCACertificateBackendCryptography, self).needs_regeneration(not_before=self.notBefore, not_after=self.notAfter): + return True + + self._ensure_existing_certificate_loaded() + + # Check whether certificate is signed by CA certificate + if not cryptography_verify_certificate_signature(self.existing_certificate, self.ca_cert.public_key()): + return True + + # Check subject + if self.ca_cert.subject != self.existing_certificate.issuer: + return True + + # Check AuthorityKeyIdentifier + if self.create_authority_key_identifier: + try: + ext = self.ca_cert.extensions.get_extension_for_class(x509.SubjectKeyIdentifier) + expected_ext = ( + x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(ext.value) + if CRYPTOGRAPHY_VERSION >= LooseVersion('2.7') else + x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(ext) + ) + except cryptography.x509.ExtensionNotFound: + expected_ext = x509.AuthorityKeyIdentifier.from_issuer_public_key(self.ca_cert.public_key()) + + try: + ext = self.existing_certificate.extensions.get_extension_for_class(x509.AuthorityKeyIdentifier) + if ext.value != expected_ext: + return True + except cryptography.x509.ExtensionNotFound as dummy: + return True + + return False + + def dump(self, include_certificate): + result = super(OwnCACertificateBackendCryptography, self).dump(include_certificate) + result.update({ + 'ca_cert': self.ca_cert_path, + 'ca_privatekey': self.ca_privatekey_path, + }) + + if self.module.check_mode: + result.update({ + 'notBefore': self.notBefore.strftime("%Y%m%d%H%M%SZ"), + 'notAfter': self.notAfter.strftime("%Y%m%d%H%M%SZ"), + 'serial_number': self.serial_number, + }) + else: + if self.cert is None: + self.cert = self.existing_certificate + result.update({ + 'notBefore': self.cert.not_valid_before.strftime("%Y%m%d%H%M%SZ"), + 'notAfter': self.cert.not_valid_after.strftime("%Y%m%d%H%M%SZ"), + 'serial_number': cryptography_serial_number_of_cert(self.cert), + }) + + return result + + +def generate_serial_number(): + """Generate a serial number for a certificate""" + while True: + result = randrange(0, 1 << 160) + if result >= 1000: + return result + + +class OwnCACertificateProvider(CertificateProvider): + def validate_module_args(self, module): + if module.params['ownca_path'] is None and module.params['ownca_content'] is None: + module.fail_json(msg='One of ownca_path and ownca_content must be specified for the ownca provider.') + if module.params['ownca_privatekey_path'] is None and module.params['ownca_privatekey_content'] is None: + module.fail_json(msg='One of ownca_privatekey_path and ownca_privatekey_content must be specified for the ownca provider.') + + def needs_version_two_certs(self, module): + return module.params['ownca_version'] == 2 + + def create_backend(self, module, backend): + if backend == 'cryptography': + return OwnCACertificateBackendCryptography(module) + + +def add_ownca_provider_to_argument_spec(argument_spec): + argument_spec.argument_spec['provider']['choices'].append('ownca') + argument_spec.argument_spec.update(dict( + ownca_path=dict(type='path'), + ownca_content=dict(type='str'), + ownca_privatekey_path=dict(type='path'), + ownca_privatekey_content=dict(type='str', no_log=True), + ownca_privatekey_passphrase=dict(type='str', no_log=True), + ownca_digest=dict(type='str', default='sha256'), + ownca_version=dict(type='int', default=3), + ownca_not_before=dict(type='str', default='+0s'), + ownca_not_after=dict(type='str', default='+3650d'), + ownca_create_subject_key_identifier=dict( + type='str', + default='create_if_not_provided', + choices=['create_if_not_provided', 'always_create', 'never_create'] + ), + ownca_create_authority_key_identifier=dict(type='bool', default=True), + )) + argument_spec.mutually_exclusive.extend([ + ['ownca_path', 'ownca_content'], + ['ownca_privatekey_path', 'ownca_privatekey_content'], + ]) 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 new file mode 100644 index 00000000..8695d43e --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/certificate_selfsigned.py @@ -0,0 +1,198 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2016-2017, Yanis Guenane <yanis+ansible@guenane.org> +# Copyright (c) 2017, Markus Teufelberger <mteufelberger+ansible@mgit.at> +# 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 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_key_needs_digest_for_signing, + cryptography_serial_number_of_cert, + cryptography_verify_certificate_signature, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.certificate import ( + CertificateError, + CertificateBackend, + CertificateProvider, +) + +try: + import cryptography + from cryptography import x509 + from cryptography.hazmat.backends import default_backend + from cryptography.hazmat.primitives.serialization import Encoding +except ImportError: + pass + + +class SelfSignedCertificateBackendCryptography(CertificateBackend): + def __init__(self, module): + 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.digest = select_message_digest(module.params['selfsigned_digest']) + self.version = module.params['selfsigned_version'] + self.serial_number = x509.random_serial_number() + + if self.csr_path is not None and not os.path.exists(self.csr_path): + raise CertificateError( + 'The certificate signing request file {0} does not exist'.format(self.csr_path) + ) + if self.privatekey_content is None and not os.path.exists(self.privatekey_path): + raise CertificateError( + 'The private key file {0} does not exist'.format(self.privatekey_path) + ) + + self._module = module + + self._ensure_private_key_loaded() + + self._ensure_csr_loaded() + if self.csr is None: + # Create empty CSR on the fly + csr = cryptography.x509.CertificateSigningRequestBuilder() + csr = csr.subject_name(cryptography.x509.Name([])) + digest = None + if cryptography_key_needs_digest_for_signing(self.privatekey): + digest = self.digest + if digest is None: + self.module.fail_json(msg='Unsupported digest "{0}"'.format(module.params['selfsigned_digest'])) + try: + self.csr = csr.sign(self.privatekey, digest, default_backend()) + except TypeError as e: + if str(e) == 'Algorithm must be a registered hash algorithm.' and digest is None: + self.module.fail_json(msg='Signing with Ed25519 and Ed448 keys requires cryptography 2.8 or newer.') + raise + + if cryptography_key_needs_digest_for_signing(self.privatekey): + if self.digest is None: + raise CertificateError( + 'The digest %s is not supported with the cryptography backend' % module.params['selfsigned_digest'] + ) + else: + self.digest = None + + def generate_certificate(self): + """(Re-)Generate certificate.""" + try: + cert_builder = x509.CertificateBuilder() + cert_builder = cert_builder.subject_name(self.csr.subject) + cert_builder = cert_builder.issuer_name(self.csr.subject) + cert_builder = cert_builder.serial_number(self.serial_number) + cert_builder = cert_builder.not_valid_before(self.notBefore) + cert_builder = cert_builder.not_valid_after(self.notAfter) + cert_builder = cert_builder.public_key(self.privatekey.public_key()) + has_ski = False + for extension in self.csr.extensions: + if isinstance(extension.value, x509.SubjectKeyIdentifier): + if self.create_subject_key_identifier == 'always_create': + continue + has_ski = True + cert_builder = cert_builder.add_extension(extension.value, critical=extension.critical) + if not has_ski and self.create_subject_key_identifier != 'never_create': + cert_builder = cert_builder.add_extension( + x509.SubjectKeyIdentifier.from_public_key(self.privatekey.public_key()), + critical=False + ) + except ValueError as e: + raise CertificateError(str(e)) + + try: + certificate = cert_builder.sign( + private_key=self.privatekey, algorithm=self.digest, + backend=default_backend() + ) + except TypeError as e: + if str(e) == 'Algorithm must be a registered hash algorithm.' and self.digest is None: + self.module.fail_json(msg='Signing with Ed25519 and Ed448 keys requires cryptography 2.8 or newer.') + raise + + self.cert = certificate + + def get_certificate_data(self): + """Return bytes for self.cert.""" + return self.cert.public_bytes(Encoding.PEM) + + def needs_regeneration(self): + if super(SelfSignedCertificateBackendCryptography, self).needs_regeneration(not_before=self.notBefore, not_after=self.notAfter): + return True + + self._ensure_existing_certificate_loaded() + + # Check whether certificate is signed by private key + if not cryptography_verify_certificate_signature(self.existing_certificate, self.privatekey.public_key()): + return True + + return False + + def dump(self, include_certificate): + result = super(SelfSignedCertificateBackendCryptography, self).dump(include_certificate) + + if self.module.check_mode: + result.update({ + 'notBefore': self.notBefore.strftime("%Y%m%d%H%M%SZ"), + 'notAfter': self.notAfter.strftime("%Y%m%d%H%M%SZ"), + 'serial_number': self.serial_number, + }) + else: + if self.cert is None: + self.cert = self.existing_certificate + result.update({ + 'notBefore': self.cert.not_valid_before.strftime("%Y%m%d%H%M%SZ"), + 'notAfter': self.cert.not_valid_after.strftime("%Y%m%d%H%M%SZ"), + 'serial_number': cryptography_serial_number_of_cert(self.cert), + }) + + return result + + +def generate_serial_number(): + """Generate a serial number for a certificate""" + while True: + result = randrange(0, 1 << 160) + if result >= 1000: + return result + + +class SelfSignedCertificateProvider(CertificateProvider): + def validate_module_args(self, module): + if module.params['privatekey_path'] is None and module.params['privatekey_content'] is None: + module.fail_json(msg='One of privatekey_path and privatekey_content must be specified for the selfsigned provider.') + + def needs_version_two_certs(self, module): + return module.params['selfsigned_version'] == 2 + + def create_backend(self, module, backend): + if backend == 'cryptography': + return SelfSignedCertificateBackendCryptography(module) + + +def add_selfsigned_provider_to_argument_spec(argument_spec): + argument_spec.argument_spec['provider']['choices'].append('selfsigned') + argument_spec.argument_spec.update(dict( + selfsigned_version=dict(type='int', default=3), + selfsigned_digest=dict(type='str', default='sha256'), + selfsigned_not_before=dict(type='str', default='+0s', aliases=['selfsigned_notBefore']), + selfsigned_not_after=dict(type='str', default='+3650d', aliases=['selfsigned_notAfter']), + selfsigned_create_subject_key_identifier=dict( + type='str', + default='create_if_not_provided', + choices=['create_if_not_provided', 'always_create', 'never_create'] + ), + )) 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 new file mode 100644 index 00000000..67f87dd0 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/common.py @@ -0,0 +1,35 @@ +# -*- 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 + + +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 {} + + 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) diff --git a/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/crl_info.py b/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/crl_info.py new file mode 100644 index 00000000..a5b1b8ec --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/crl_info.py @@ -0,0 +1,102 @@ +# -*- 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 + + +import traceback + +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.crypto.cryptography_support import ( + cryptography_oid_to_name, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_crl import ( + TIMESTAMP_FORMAT, + cryptography_decode_revoked_certificate, + cryptography_dump_revoked, + cryptography_get_signature_algorithm_oid_from_crl, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.pem import ( + identify_pem_format, +) + +# crypto_utils + +MINIMAL_CRYPTOGRAPHY_VERSION = '1.2' + +CRYPTOGRAPHY_IMP_ERR = None +try: + import cryptography + from cryptography import x509 + from cryptography.hazmat.backends import default_backend + CRYPTOGRAPHY_VERSION = LooseVersion(cryptography.__version__) +except ImportError: + CRYPTOGRAPHY_IMP_ERR = traceback.format_exc() + CRYPTOGRAPHY_FOUND = False +else: + CRYPTOGRAPHY_FOUND = True + + +class CRLInfoRetrieval(object): + def __init__(self, module, content, list_revoked_certificates=True): + # content must be a bytes string + self.module = module + self.content = content + self.list_revoked_certificates = list_revoked_certificates + self.name_encoding = module.params.get('name_encoding', 'ignore') + + def get_info(self): + self.crl_pem = identify_pem_format(self.content) + try: + if self.crl_pem: + self.crl = x509.load_pem_x509_crl(self.content, default_backend()) + else: + self.crl = x509.load_der_x509_crl(self.content, default_backend()) + except ValueError as e: + self.module.fail_json(msg='Error while decoding CRL: {0}'.format(e)) + + result = { + 'changed': False, + 'format': 'pem' if self.crl_pem else 'der', + 'last_update': None, + 'next_update': None, + 'digest': None, + 'issuer_ordered': None, + 'issuer': None, + } + + result['last_update'] = self.crl.last_update.strftime(TIMESTAMP_FORMAT) + result['next_update'] = self.crl.next_update.strftime(TIMESTAMP_FORMAT) + result['digest'] = cryptography_oid_to_name(cryptography_get_signature_algorithm_oid_from_crl(self.crl)) + issuer = [] + for attribute in self.crl.issuer: + issuer.append([cryptography_oid_to_name(attribute.oid), attribute.value]) + result['issuer_ordered'] = issuer + result['issuer'] = {} + for k, v in issuer: + result['issuer'][k] = v + if self.list_revoked_certificates: + result['revoked_certificates'] = [] + for cert in self.crl: + entry = cryptography_decode_revoked_certificate(cert) + result['revoked_certificates'].append(cryptography_dump_revoked(entry, idn_rewrite=self.name_encoding)) + + return result + + +def get_crl_info(module, content, list_revoked_certificates=True): + if not CRYPTOGRAPHY_FOUND: + module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)), + exception=CRYPTOGRAPHY_IMP_ERR) + + info = CRLInfoRetrieval(module, content, list_revoked_certificates=list_revoked_certificates) + return info.get_info() 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 new file mode 100644 index 00000000..4ab14e52 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/csr.py @@ -0,0 +1,675 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2016, Yanis Guenane <yanis+ansible@guenane.org> +# 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 + + +import abc +import binascii +import traceback + +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.version import LooseVersion + +from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import ( + OpenSSLObjectError, + OpenSSLBadPassphraseError, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.support import ( + load_privatekey, + load_certificate_request, + parse_name_field, + parse_ordered_name_field, + select_message_digest, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_support import ( + cryptography_get_basic_constraints, + cryptography_get_name, + cryptography_name_to_oid, + cryptography_key_needs_digest_for_signing, + cryptography_parse_key_usage_params, + cryptography_parse_relative_distinguished_name, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_crl import ( + REVOCATION_REASON_MAP, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.csr_info import ( + get_csr_info, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.common import ArgumentSpec + + +MINIMAL_CRYPTOGRAPHY_VERSION = '1.3' + +CRYPTOGRAPHY_IMP_ERR = None +try: + import cryptography + import cryptography.x509 + import cryptography.x509.oid + import cryptography.exceptions + import cryptography.hazmat.backends + import cryptography.hazmat.primitives.serialization + import cryptography.hazmat.primitives.hashes + CRYPTOGRAPHY_VERSION = LooseVersion(cryptography.__version__) +except ImportError: + CRYPTOGRAPHY_IMP_ERR = traceback.format_exc() + CRYPTOGRAPHY_FOUND = False +else: + CRYPTOGRAPHY_FOUND = True + CRYPTOGRAPHY_MUST_STAPLE_NAME = cryptography.x509.oid.ObjectIdentifier("1.3.6.1.5.5.7.1.24") + CRYPTOGRAPHY_MUST_STAPLE_VALUE = b"\x30\x03\x02\x01\x05" + + +class CertificateSigningRequestError(OpenSSLObjectError): + pass + + +# From the object called `module`, only the following properties are used: +# +# - module.params[] +# - module.warn(msg: str) +# - module.fail_json(msg: str, **kwargs) + + +@six.add_metaclass(abc.ABCMeta) +class CertificateSigningRequestBackend(object): + def __init__(self, module, backend): + self.module = module + self.backend = backend + self.digest = module.params['digest'] + self.privatekey_path = module.params['privatekey_path'] + self.privatekey_content = module.params['privatekey_content'] + if self.privatekey_content is not None: + self.privatekey_content = self.privatekey_content.encode('utf-8') + self.privatekey_passphrase = module.params['privatekey_passphrase'] + self.version = module.params['version'] + self.subjectAltName = module.params['subject_alt_name'] + self.subjectAltName_critical = module.params['subject_alt_name_critical'] + self.keyUsage = module.params['key_usage'] + self.keyUsage_critical = module.params['key_usage_critical'] + self.extendedKeyUsage = module.params['extended_key_usage'] + self.extendedKeyUsage_critical = module.params['extended_key_usage_critical'] + self.basicConstraints = module.params['basic_constraints'] + self.basicConstraints_critical = module.params['basic_constraints_critical'] + self.ocspMustStaple = module.params['ocsp_must_staple'] + self.ocspMustStaple_critical = module.params['ocsp_must_staple_critical'] + self.name_constraints_permitted = module.params['name_constraints_permitted'] or [] + self.name_constraints_excluded = module.params['name_constraints_excluded'] or [] + self.name_constraints_critical = module.params['name_constraints_critical'] + self.create_subject_key_identifier = module.params['create_subject_key_identifier'] + self.subject_key_identifier = module.params['subject_key_identifier'] + self.authority_key_identifier = module.params['authority_key_identifier'] + self.authority_cert_issuer = module.params['authority_cert_issuer'] + self.authority_cert_serial_number = module.params['authority_cert_serial_number'] + self.crl_distribution_points = module.params['crl_distribution_points'] + self.csr = None + self.privatekey = None + + if self.create_subject_key_identifier and self.subject_key_identifier is not None: + module.fail_json(msg='subject_key_identifier cannot be specified if create_subject_key_identifier is true') + + self.ordered_subject = False + self.subject = [ + ('C', module.params['country_name']), + ('ST', module.params['state_or_province_name']), + ('L', module.params['locality_name']), + ('O', module.params['organization_name']), + ('OU', module.params['organizational_unit_name']), + ('CN', module.params['common_name']), + ('emailAddress', module.params['email_address']), + ] + self.subject = [(entry[0], entry[1]) for entry in self.subject if entry[1]] + + try: + if module.params['subject']: + self.subject = self.subject + parse_name_field(module.params['subject'], 'subject') + if module.params['subject_ordered']: + if self.subject: + raise CertificateSigningRequestError('subject_ordered cannot be combined with any other subject field') + self.subject = parse_ordered_name_field(module.params['subject_ordered'], 'subject_ordered') + self.ordered_subject = True + except ValueError as exc: + raise CertificateSigningRequestError(to_native(exc)) + + self.using_common_name_for_san = False + if not self.subjectAltName and module.params['use_common_name_for_san']: + for sub in self.subject: + if sub[0] in ('commonName', 'CN'): + self.subjectAltName = ['DNS:%s' % sub[1]] + self.using_common_name_for_san = True + break + + if self.subject_key_identifier is not None: + try: + self.subject_key_identifier = binascii.unhexlify(self.subject_key_identifier.replace(':', '')) + except Exception as e: + raise CertificateSigningRequestError('Cannot parse subject_key_identifier: {0}'.format(e)) + + if self.authority_key_identifier is not None: + try: + self.authority_key_identifier = binascii.unhexlify(self.authority_key_identifier.replace(':', '')) + except Exception as e: + raise CertificateSigningRequestError('Cannot parse authority_key_identifier: {0}'.format(e)) + + self.existing_csr = None + self.existing_csr_bytes = None + + self.diff_before = self._get_info(None) + self.diff_after = self._get_info(None) + + def _get_info(self, data): + if data is None: + return dict() + try: + result = get_csr_info( + self.module, self.backend, data, validate_signature=False, prefer_one_fingerprint=True) + result['can_parse_csr'] = True + return result + except Exception as exc: + return dict(can_parse_csr=False) + + @abc.abstractmethod + def generate_csr(self): + """(Re-)Generate CSR.""" + pass + + @abc.abstractmethod + def get_csr_data(self): + """Return bytes for self.csr.""" + pass + + def set_existing(self, csr_bytes): + """Set existing CSR bytes. None indicates that the CSR does not exist.""" + self.existing_csr_bytes = csr_bytes + self.diff_after = self.diff_before = self._get_info(self.existing_csr_bytes) + + def has_existing(self): + """Query whether an existing CSR is/has been there.""" + return self.existing_csr_bytes is not None + + def _ensure_private_key_loaded(self): + """Load the provided private key into self.privatekey.""" + if self.privatekey is not None: + return + try: + self.privatekey = load_privatekey( + path=self.privatekey_path, + content=self.privatekey_content, + passphrase=self.privatekey_passphrase, + backend=self.backend, + ) + except OpenSSLBadPassphraseError as exc: + raise CertificateSigningRequestError(exc) + + @abc.abstractmethod + def _check_csr(self): + """Check whether provided parameters, assuming self.existing_csr and self.privatekey have been populated.""" + pass + + def needs_regeneration(self): + """Check whether a regeneration is necessary.""" + if self.existing_csr_bytes is None: + return True + try: + self.existing_csr = load_certificate_request(None, content=self.existing_csr_bytes, backend=self.backend) + except Exception as dummy: + return True + self._ensure_private_key_loaded() + return not self._check_csr() + + def dump(self, include_csr): + """Serialize the object into a dictionary.""" + result = { + 'privatekey': self.privatekey_path, + 'subject': self.subject, + 'subjectAltName': self.subjectAltName, + 'keyUsage': self.keyUsage, + 'extendedKeyUsage': self.extendedKeyUsage, + 'basicConstraints': self.basicConstraints, + 'ocspMustStaple': self.ocspMustStaple, + 'name_constraints_permitted': self.name_constraints_permitted, + 'name_constraints_excluded': self.name_constraints_excluded, + } + # Get hold of CSR bytes + csr_bytes = self.existing_csr_bytes + if self.csr is not None: + csr_bytes = self.get_csr_data() + self.diff_after = self._get_info(csr_bytes) + if include_csr: + # Store result + result['csr'] = csr_bytes.decode('utf-8') if csr_bytes else None + + result['diff'] = dict( + before=self.diff_before, + after=self.diff_after, + ) + return result + + +def parse_crl_distribution_points(module, crl_distribution_points): + result = [] + for index, parse_crl_distribution_point in enumerate(crl_distribution_points): + try: + params = dict( + full_name=None, + relative_name=None, + crl_issuer=None, + reasons=None, + ) + if parse_crl_distribution_point['full_name'] is not None: + if not parse_crl_distribution_point['full_name']: + raise OpenSSLObjectError('full_name must not be empty') + params['full_name'] = [cryptography_get_name(name, 'full name') for name in parse_crl_distribution_point['full_name']] + if parse_crl_distribution_point['relative_name'] is not None: + if not parse_crl_distribution_point['relative_name']: + raise OpenSSLObjectError('relative_name must not be empty') + try: + params['relative_name'] = cryptography_parse_relative_distinguished_name(parse_crl_distribution_point['relative_name']) + except Exception: + # If cryptography's version is < 1.6, the error is probably caused by that + if CRYPTOGRAPHY_VERSION < LooseVersion('1.6'): + raise OpenSSLObjectError('Cannot specify relative_name for cryptography < 1.6') + raise + if parse_crl_distribution_point['crl_issuer'] is not None: + if not parse_crl_distribution_point['crl_issuer']: + raise OpenSSLObjectError('crl_issuer must not be empty') + params['crl_issuer'] = [cryptography_get_name(name, 'CRL issuer') for name in parse_crl_distribution_point['crl_issuer']] + if parse_crl_distribution_point['reasons'] is not None: + reasons = [] + for reason in parse_crl_distribution_point['reasons']: + reasons.append(REVOCATION_REASON_MAP[reason]) + params['reasons'] = frozenset(reasons) + result.append(cryptography.x509.DistributionPoint(**params)) + except (OpenSSLObjectError, ValueError) as e: + raise OpenSSLObjectError('Error while parsing CRL distribution point #{index}: {error}'.format(index=index, error=e)) + return result + + +# Implementation with using cryptography +class CertificateSigningRequestCryptographyBackend(CertificateSigningRequestBackend): + def __init__(self, module): + super(CertificateSigningRequestCryptographyBackend, self).__init__(module, 'cryptography') + self.cryptography_backend = cryptography.hazmat.backends.default_backend() + if self.version != 1: + module.warn('The cryptography backend only supports version 1. (The only valid value according to RFC 2986.)') + + if self.crl_distribution_points: + self.crl_distribution_points = parse_crl_distribution_points(module, self.crl_distribution_points) + + def generate_csr(self): + """(Re-)Generate CSR.""" + self._ensure_private_key_loaded() + + csr = cryptography.x509.CertificateSigningRequestBuilder() + try: + csr = csr.subject_name(cryptography.x509.Name([ + cryptography.x509.NameAttribute(cryptography_name_to_oid(entry[0]), to_text(entry[1])) for entry in self.subject + ])) + except ValueError as e: + raise CertificateSigningRequestError(e) + + if self.subjectAltName: + csr = csr.add_extension(cryptography.x509.SubjectAlternativeName([ + cryptography_get_name(name) for name in self.subjectAltName + ]), critical=self.subjectAltName_critical) + + if self.keyUsage: + params = cryptography_parse_key_usage_params(self.keyUsage) + csr = csr.add_extension(cryptography.x509.KeyUsage(**params), critical=self.keyUsage_critical) + + if self.extendedKeyUsage: + usages = [cryptography_name_to_oid(usage) for usage in self.extendedKeyUsage] + csr = csr.add_extension(cryptography.x509.ExtendedKeyUsage(usages), critical=self.extendedKeyUsage_critical) + + if self.basicConstraints: + params = {} + ca, path_length = cryptography_get_basic_constraints(self.basicConstraints) + csr = csr.add_extension(cryptography.x509.BasicConstraints(ca, path_length), critical=self.basicConstraints_critical) + + if self.ocspMustStaple: + try: + # This only works with cryptography >= 2.1 + csr = csr.add_extension(cryptography.x509.TLSFeature([cryptography.x509.TLSFeatureType.status_request]), critical=self.ocspMustStaple_critical) + except AttributeError as dummy: + csr = csr.add_extension( + cryptography.x509.UnrecognizedExtension(CRYPTOGRAPHY_MUST_STAPLE_NAME, CRYPTOGRAPHY_MUST_STAPLE_VALUE), + critical=self.ocspMustStaple_critical + ) + + if self.name_constraints_permitted or self.name_constraints_excluded: + try: + csr = csr.add_extension(cryptography.x509.NameConstraints( + [cryptography_get_name(name, 'name constraints permitted') for name in self.name_constraints_permitted] or None, + [cryptography_get_name(name, 'name constraints excluded') for name in self.name_constraints_excluded] or None, + ), critical=self.name_constraints_critical) + except TypeError as e: + raise OpenSSLObjectError('Error while parsing name constraint: {0}'.format(e)) + + if self.create_subject_key_identifier: + csr = csr.add_extension( + cryptography.x509.SubjectKeyIdentifier.from_public_key(self.privatekey.public_key()), + critical=False + ) + elif self.subject_key_identifier is not None: + csr = csr.add_extension(cryptography.x509.SubjectKeyIdentifier(self.subject_key_identifier), critical=False) + + if self.authority_key_identifier is not None or self.authority_cert_issuer is not None or self.authority_cert_serial_number is not None: + issuers = None + if self.authority_cert_issuer is not None: + issuers = [cryptography_get_name(n, 'authority cert issuer') for n in self.authority_cert_issuer] + csr = csr.add_extension( + cryptography.x509.AuthorityKeyIdentifier(self.authority_key_identifier, issuers, self.authority_cert_serial_number), + critical=False + ) + + if self.crl_distribution_points: + csr = csr.add_extension( + cryptography.x509.CRLDistributionPoints(self.crl_distribution_points), + critical=False + ) + + digest = None + if cryptography_key_needs_digest_for_signing(self.privatekey): + digest = select_message_digest(self.digest) + if digest is None: + raise CertificateSigningRequestError('Unsupported digest "{0}"'.format(self.digest)) + try: + self.csr = csr.sign(self.privatekey, digest, self.cryptography_backend) + except TypeError as e: + if str(e) == 'Algorithm must be a registered hash algorithm.' and digest is None: + self.module.fail_json(msg='Signing with Ed25519 and Ed448 keys requires cryptography 2.8 or newer.') + raise + except UnicodeError as e: + # This catches IDNAErrors, which happens when a bad name is passed as a SAN + # (https://github.com/ansible-collections/community.crypto/issues/105). + # For older cryptography versions, this is handled by idna, which raises + # an idna.core.IDNAError. Later versions of cryptography deprecated and stopped + # requiring idna, whence we cannot easily handle this error. Fortunately, in + # most versions of idna, IDNAError extends UnicodeError. There is only version + # 2.3 where it extends Exception instead (see + # https://github.com/kjd/idna/commit/ebefacd3134d0f5da4745878620a6a1cba86d130 + # and then + # https://github.com/kjd/idna/commit/ea03c7b5db7d2a99af082e0239da2b68aeea702a). + msg = 'Error while creating CSR: {0}\n'.format(e) + if self.using_common_name_for_san: + self.module.fail_json(msg=msg + 'This is probably caused because the Common Name is used as a SAN.' + ' Specifying use_common_name_for_san=false might fix this.') + self.module.fail_json(msg=msg + 'This is probably caused by an invalid Subject Alternative DNS Name.') + + def get_csr_data(self): + """Return bytes for self.csr.""" + return self.csr.public_bytes(cryptography.hazmat.primitives.serialization.Encoding.PEM) + + def _check_csr(self): + """Check whether provided parameters, assuming self.existing_csr and self.privatekey have been populated.""" + def _check_subject(csr): + subject = [(cryptography_name_to_oid(entry[0]), to_text(entry[1])) for entry in self.subject] + current_subject = [(sub.oid, sub.value) for sub in csr.subject] + if self.ordered_subject: + return subject == current_subject + else: + return set(subject) == set(current_subject) + + def _find_extension(extensions, exttype): + return next( + (ext for ext in extensions if isinstance(ext.value, exttype)), + None + ) + + def _check_subjectAltName(extensions): + current_altnames_ext = _find_extension(extensions, cryptography.x509.SubjectAlternativeName) + current_altnames = [to_text(altname) for altname in current_altnames_ext.value] if current_altnames_ext else [] + altnames = [to_text(cryptography_get_name(altname)) for altname in self.subjectAltName] if self.subjectAltName else [] + if set(altnames) != set(current_altnames): + return False + if altnames: + if current_altnames_ext.critical != self.subjectAltName_critical: + return False + return True + + def _check_keyUsage(extensions): + current_keyusage_ext = _find_extension(extensions, cryptography.x509.KeyUsage) + if not self.keyUsage: + return current_keyusage_ext is None + elif current_keyusage_ext is None: + return False + params = cryptography_parse_key_usage_params(self.keyUsage) + for param in params: + if getattr(current_keyusage_ext.value, '_' + param) != params[param]: + return False + if current_keyusage_ext.critical != self.keyUsage_critical: + return False + return True + + def _check_extenededKeyUsage(extensions): + current_usages_ext = _find_extension(extensions, cryptography.x509.ExtendedKeyUsage) + current_usages = [str(usage) for usage in current_usages_ext.value] if current_usages_ext else [] + usages = [str(cryptography_name_to_oid(usage)) for usage in self.extendedKeyUsage] if self.extendedKeyUsage else [] + if set(current_usages) != set(usages): + return False + if usages: + if current_usages_ext.critical != self.extendedKeyUsage_critical: + return False + return True + + def _check_basicConstraints(extensions): + bc_ext = _find_extension(extensions, cryptography.x509.BasicConstraints) + current_ca = bc_ext.value.ca if bc_ext else False + current_path_length = bc_ext.value.path_length if bc_ext else None + ca, path_length = cryptography_get_basic_constraints(self.basicConstraints) + # Check CA flag + if ca != current_ca: + return False + # Check path length + if path_length != current_path_length: + return False + # Check criticality + if self.basicConstraints: + return bc_ext is not None and bc_ext.critical == self.basicConstraints_critical + else: + return bc_ext is None + + def _check_ocspMustStaple(extensions): + try: + # This only works with cryptography >= 2.1 + tlsfeature_ext = _find_extension(extensions, cryptography.x509.TLSFeature) + has_tlsfeature = True + except AttributeError as dummy: + tlsfeature_ext = next( + (ext for ext in extensions if ext.value.oid == CRYPTOGRAPHY_MUST_STAPLE_NAME), + None + ) + has_tlsfeature = False + if self.ocspMustStaple: + if not tlsfeature_ext or tlsfeature_ext.critical != self.ocspMustStaple_critical: + return False + if has_tlsfeature: + return cryptography.x509.TLSFeatureType.status_request in tlsfeature_ext.value + else: + return tlsfeature_ext.value.value == CRYPTOGRAPHY_MUST_STAPLE_VALUE + else: + return tlsfeature_ext is None + + def _check_nameConstraints(extensions): + current_nc_ext = _find_extension(extensions, cryptography.x509.NameConstraints) + current_nc_perm = [to_text(altname) for altname in current_nc_ext.value.permitted_subtrees or []] if current_nc_ext else [] + current_nc_excl = [to_text(altname) for altname in current_nc_ext.value.excluded_subtrees or []] if current_nc_ext else [] + nc_perm = [to_text(cryptography_get_name(altname, 'name constraints permitted')) for altname in self.name_constraints_permitted] + nc_excl = [to_text(cryptography_get_name(altname, 'name constraints excluded')) for altname in self.name_constraints_excluded] + if set(nc_perm) != set(current_nc_perm) or set(nc_excl) != set(current_nc_excl): + return False + if nc_perm or nc_excl: + if current_nc_ext.critical != self.name_constraints_critical: + return False + return True + + def _check_subject_key_identifier(extensions): + ext = _find_extension(extensions, cryptography.x509.SubjectKeyIdentifier) + if self.create_subject_key_identifier or self.subject_key_identifier is not None: + if not ext or ext.critical: + return False + if self.create_subject_key_identifier: + digest = cryptography.x509.SubjectKeyIdentifier.from_public_key(self.privatekey.public_key()).digest + return ext.value.digest == digest + else: + return ext.value.digest == self.subject_key_identifier + else: + return ext is None + + def _check_authority_key_identifier(extensions): + ext = _find_extension(extensions, cryptography.x509.AuthorityKeyIdentifier) + if self.authority_key_identifier is not None or self.authority_cert_issuer is not None or self.authority_cert_serial_number is not None: + if not ext or ext.critical: + return False + aci = None + csr_aci = None + if self.authority_cert_issuer is not None: + aci = [to_text(cryptography_get_name(n, 'authority cert issuer')) for n in self.authority_cert_issuer] + if ext.value.authority_cert_issuer is not None: + csr_aci = [to_text(n) for n in ext.value.authority_cert_issuer] + return (ext.value.key_identifier == self.authority_key_identifier + and csr_aci == aci + and ext.value.authority_cert_serial_number == self.authority_cert_serial_number) + else: + return ext is None + + def _check_crl_distribution_points(extensions): + ext = _find_extension(extensions, cryptography.x509.CRLDistributionPoints) + if self.crl_distribution_points is None: + return ext is None + if not ext: + return False + return list(ext.value) == self.crl_distribution_points + + def _check_extensions(csr): + extensions = csr.extensions + return (_check_subjectAltName(extensions) and _check_keyUsage(extensions) and + _check_extenededKeyUsage(extensions) and _check_basicConstraints(extensions) and + _check_ocspMustStaple(extensions) and _check_subject_key_identifier(extensions) and + _check_authority_key_identifier(extensions) and _check_nameConstraints(extensions) and + _check_crl_distribution_points(extensions)) + + def _check_signature(csr): + if not csr.is_signature_valid: + return False + # To check whether public key of CSR belongs to private key, + # encode both public keys and compare PEMs. + key_a = csr.public_key().public_bytes( + cryptography.hazmat.primitives.serialization.Encoding.PEM, + cryptography.hazmat.primitives.serialization.PublicFormat.SubjectPublicKeyInfo + ) + key_b = self.privatekey.public_key().public_bytes( + cryptography.hazmat.primitives.serialization.Encoding.PEM, + cryptography.hazmat.primitives.serialization.PublicFormat.SubjectPublicKeyInfo + ) + return key_a == key_b + + return _check_subject(self.existing_csr) and _check_extensions(self.existing_csr) and _check_signature(self.existing_csr) + + +def select_backend(module, backend): + if backend == 'auto': + # Detection what is possible + can_use_cryptography = CRYPTOGRAPHY_FOUND and CRYPTOGRAPHY_VERSION >= LooseVersion(MINIMAL_CRYPTOGRAPHY_VERSION) + + # Try cryptography + if can_use_cryptography: + backend = 'cryptography' + + # Success? + if backend == 'auto': + module.fail_json(msg=("Cannot detect any of the required Python libraries " + "cryptography (>= {0})").format(MINIMAL_CRYPTOGRAPHY_VERSION)) + + if backend == 'cryptography': + if not CRYPTOGRAPHY_FOUND: + module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)), + exception=CRYPTOGRAPHY_IMP_ERR) + return backend, CertificateSigningRequestCryptographyBackend(module) + else: + raise Exception('Unsupported value for backend: {0}'.format(backend)) + + +def get_csr_argument_spec(): + return ArgumentSpec( + argument_spec=dict( + digest=dict(type='str', default='sha256'), + privatekey_path=dict(type='path'), + privatekey_content=dict(type='str', no_log=True), + privatekey_passphrase=dict(type='str', no_log=True), + version=dict(type='int', default=1, choices=[1]), + subject=dict(type='dict'), + subject_ordered=dict(type='list', elements='dict'), + country_name=dict(type='str', aliases=['C', 'countryName']), + state_or_province_name=dict(type='str', aliases=['ST', 'stateOrProvinceName']), + locality_name=dict(type='str', aliases=['L', 'localityName']), + organization_name=dict(type='str', aliases=['O', 'organizationName']), + organizational_unit_name=dict(type='str', aliases=['OU', 'organizationalUnitName']), + common_name=dict(type='str', aliases=['CN', 'commonName']), + email_address=dict(type='str', aliases=['E', 'emailAddress']), + subject_alt_name=dict(type='list', elements='str', aliases=['subjectAltName']), + subject_alt_name_critical=dict(type='bool', default=False, aliases=['subjectAltName_critical']), + use_common_name_for_san=dict(type='bool', default=True, aliases=['useCommonNameForSAN']), + key_usage=dict(type='list', elements='str', aliases=['keyUsage']), + key_usage_critical=dict(type='bool', default=False, aliases=['keyUsage_critical']), + extended_key_usage=dict(type='list', elements='str', aliases=['extKeyUsage', 'extendedKeyUsage']), + extended_key_usage_critical=dict(type='bool', default=False, aliases=['extKeyUsage_critical', 'extendedKeyUsage_critical']), + basic_constraints=dict(type='list', elements='str', aliases=['basicConstraints']), + basic_constraints_critical=dict(type='bool', default=False, aliases=['basicConstraints_critical']), + ocsp_must_staple=dict(type='bool', default=False, aliases=['ocspMustStaple']), + ocsp_must_staple_critical=dict(type='bool', default=False, aliases=['ocspMustStaple_critical']), + name_constraints_permitted=dict(type='list', elements='str'), + name_constraints_excluded=dict(type='list', elements='str'), + name_constraints_critical=dict(type='bool', default=False), + create_subject_key_identifier=dict(type='bool', default=False), + subject_key_identifier=dict(type='str'), + authority_key_identifier=dict(type='str'), + authority_cert_issuer=dict(type='list', elements='str'), + authority_cert_serial_number=dict(type='int'), + crl_distribution_points=dict( + type='list', + elements='dict', + options=dict( + full_name=dict(type='list', elements='str'), + relative_name=dict(type='list', elements='str'), + crl_issuer=dict(type='list', elements='str'), + reasons=dict(type='list', elements='str', choices=[ + 'key_compromise', + 'ca_compromise', + 'affiliation_changed', + 'superseded', + 'cessation_of_operation', + 'certificate_hold', + 'privilege_withdrawn', + 'aa_compromise', + ]), + ), + mutually_exclusive=[('full_name', 'relative_name')], + required_one_of=[('full_name', 'relative_name', 'crl_issuer')], + ), + select_crypto_backend=dict(type='str', default='auto', choices=['auto', 'cryptography']), + ), + required_together=[ + ['authority_cert_issuer', 'authority_cert_serial_number'], + ], + mutually_exclusive=[ + ['privatekey_path', 'privatekey_content'], + ['subject', 'subject_ordered'], + ], + required_one_of=[ + ['privatekey_path', 'privatekey_content'], + ], + ) diff --git a/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/csr_info.py b/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/csr_info.py new file mode 100644 index 00000000..fc3d0d3d --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/csr_info.py @@ -0,0 +1,334 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2016-2017, Yanis Guenane <yanis+ansible@guenane.org> +# Copyright (c) 2017, Markus Teufelberger <mteufelberger+ansible@mgit.at> +# 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 + + +import abc +import binascii +import traceback + +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 + +from ansible_collections.community.crypto.plugins.module_utils.version import LooseVersion + +from ansible_collections.community.crypto.plugins.module_utils.crypto.support import ( + load_certificate_request, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_support import ( + cryptography_decode_name, + cryptography_get_extensions_from_csr, + cryptography_oid_to_name, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.publickey_info import ( + get_publickey_info, +) + +MINIMAL_CRYPTOGRAPHY_VERSION = '1.3' + +CRYPTOGRAPHY_IMP_ERR = None +try: + import cryptography + from cryptography import x509 + from cryptography.hazmat.primitives import serialization + CRYPTOGRAPHY_VERSION = LooseVersion(cryptography.__version__) +except ImportError: + CRYPTOGRAPHY_IMP_ERR = traceback.format_exc() + CRYPTOGRAPHY_FOUND = False +else: + CRYPTOGRAPHY_FOUND = True + + +TIMESTAMP_FORMAT = "%Y%m%d%H%M%SZ" + + +@six.add_metaclass(abc.ABCMeta) +class CSRInfoRetrieval(object): + def __init__(self, module, backend, content, validate_signature): + # content must be a bytes string + self.module = module + self.backend = backend + self.content = content + self.validate_signature = validate_signature + + @abc.abstractmethod + def _get_subject_ordered(self): + pass + + @abc.abstractmethod + def _get_key_usage(self): + pass + + @abc.abstractmethod + def _get_extended_key_usage(self): + pass + + @abc.abstractmethod + def _get_basic_constraints(self): + pass + + @abc.abstractmethod + def _get_ocsp_must_staple(self): + pass + + @abc.abstractmethod + def _get_subject_alt_name(self): + pass + + @abc.abstractmethod + def _get_name_constraints(self): + pass + + @abc.abstractmethod + def _get_public_key_pem(self): + pass + + @abc.abstractmethod + def _get_public_key_object(self): + pass + + @abc.abstractmethod + def _get_subject_key_identifier(self): + pass + + @abc.abstractmethod + def _get_authority_key_identifier(self): + pass + + @abc.abstractmethod + def _get_all_extensions(self): + pass + + @abc.abstractmethod + def _is_signature_valid(self): + pass + + def get_info(self, prefer_one_fingerprint=False): + result = dict() + self.csr = load_certificate_request(None, content=self.content, backend=self.backend) + + subject = self._get_subject_ordered() + result['subject'] = dict() + for k, v in subject: + result['subject'][k] = v + result['subject_ordered'] = subject + result['key_usage'], result['key_usage_critical'] = self._get_key_usage() + result['extended_key_usage'], result['extended_key_usage_critical'] = self._get_extended_key_usage() + result['basic_constraints'], result['basic_constraints_critical'] = self._get_basic_constraints() + result['ocsp_must_staple'], result['ocsp_must_staple_critical'] = self._get_ocsp_must_staple() + result['subject_alt_name'], result['subject_alt_name_critical'] = self._get_subject_alt_name() + ( + result['name_constraints_permitted'], + result['name_constraints_excluded'], + result['name_constraints_critical'], + ) = self._get_name_constraints() + + result['public_key'] = to_native(self._get_public_key_pem()) + + public_key_info = get_publickey_info( + self.module, + self.backend, + key=self._get_public_key_object(), + prefer_one_fingerprint=prefer_one_fingerprint) + result.update({ + 'public_key_type': public_key_info['type'], + 'public_key_data': public_key_info['public_data'], + 'public_key_fingerprints': public_key_info['fingerprints'], + }) + + ski = self._get_subject_key_identifier() + if ski is not None: + ski = to_native(binascii.hexlify(ski)) + ski = ':'.join([ski[i:i + 2] for i in range(0, len(ski), 2)]) + result['subject_key_identifier'] = ski + + aki, aci, acsn = self._get_authority_key_identifier() + if aki is not None: + aki = to_native(binascii.hexlify(aki)) + aki = ':'.join([aki[i:i + 2] for i in range(0, len(aki), 2)]) + result['authority_key_identifier'] = aki + result['authority_cert_issuer'] = aci + result['authority_cert_serial_number'] = acsn + + result['extensions_by_oid'] = self._get_all_extensions() + + result['signature_valid'] = self._is_signature_valid() + if self.validate_signature and not result['signature_valid']: + self.module.fail_json( + msg='CSR signature is invalid!', + **result + ) + return result + + +class CSRInfoRetrievalCryptography(CSRInfoRetrieval): + """Validate the supplied CSR, using the cryptography backend""" + def __init__(self, module, content, validate_signature): + super(CSRInfoRetrievalCryptography, self).__init__(module, 'cryptography', content, validate_signature) + self.name_encoding = module.params.get('name_encoding', 'ignore') + + def _get_subject_ordered(self): + result = [] + for attribute in self.csr.subject: + result.append([cryptography_oid_to_name(attribute.oid), attribute.value]) + return result + + def _get_key_usage(self): + try: + current_key_ext = self.csr.extensions.get_extension_for_class(x509.KeyUsage) + current_key_usage = current_key_ext.value + key_usage = dict( + digital_signature=current_key_usage.digital_signature, + content_commitment=current_key_usage.content_commitment, + key_encipherment=current_key_usage.key_encipherment, + data_encipherment=current_key_usage.data_encipherment, + key_agreement=current_key_usage.key_agreement, + key_cert_sign=current_key_usage.key_cert_sign, + crl_sign=current_key_usage.crl_sign, + encipher_only=False, + decipher_only=False, + ) + if key_usage['key_agreement']: + key_usage.update(dict( + encipher_only=current_key_usage.encipher_only, + decipher_only=current_key_usage.decipher_only + )) + + key_usage_names = dict( + digital_signature='Digital Signature', + content_commitment='Non Repudiation', + key_encipherment='Key Encipherment', + data_encipherment='Data Encipherment', + key_agreement='Key Agreement', + key_cert_sign='Certificate Sign', + crl_sign='CRL Sign', + encipher_only='Encipher Only', + decipher_only='Decipher Only', + ) + return sorted([ + key_usage_names[name] for name, value in key_usage.items() if value + ]), current_key_ext.critical + except cryptography.x509.ExtensionNotFound: + return None, False + + def _get_extended_key_usage(self): + try: + ext_keyusage_ext = self.csr.extensions.get_extension_for_class(x509.ExtendedKeyUsage) + return sorted([ + cryptography_oid_to_name(eku) for eku in ext_keyusage_ext.value + ]), ext_keyusage_ext.critical + except cryptography.x509.ExtensionNotFound: + return None, False + + def _get_basic_constraints(self): + try: + ext_keyusage_ext = self.csr.extensions.get_extension_for_class(x509.BasicConstraints) + result = ['CA:{0}'.format('TRUE' if ext_keyusage_ext.value.ca else 'FALSE')] + if ext_keyusage_ext.value.path_length is not None: + result.append('pathlen:{0}'.format(ext_keyusage_ext.value.path_length)) + return sorted(result), ext_keyusage_ext.critical + except cryptography.x509.ExtensionNotFound: + return None, False + + def _get_ocsp_must_staple(self): + try: + try: + # This only works with cryptography >= 2.1 + tlsfeature_ext = self.csr.extensions.get_extension_for_class(x509.TLSFeature) + value = cryptography.x509.TLSFeatureType.status_request in tlsfeature_ext.value + except AttributeError: + # Fallback for cryptography < 2.1 + oid = x509.oid.ObjectIdentifier("1.3.6.1.5.5.7.1.24") + tlsfeature_ext = self.csr.extensions.get_extension_for_oid(oid) + value = tlsfeature_ext.value.value == b"\x30\x03\x02\x01\x05" + return value, tlsfeature_ext.critical + except cryptography.x509.ExtensionNotFound: + return None, False + + def _get_subject_alt_name(self): + try: + san_ext = self.csr.extensions.get_extension_for_class(x509.SubjectAlternativeName) + result = [cryptography_decode_name(san, idn_rewrite=self.name_encoding) for san in san_ext.value] + return result, san_ext.critical + except cryptography.x509.ExtensionNotFound: + return None, False + + def _get_name_constraints(self): + try: + nc_ext = self.csr.extensions.get_extension_for_class(x509.NameConstraints) + permitted = [cryptography_decode_name(san, idn_rewrite=self.name_encoding) for san in nc_ext.value.permitted_subtrees or []] + excluded = [cryptography_decode_name(san, idn_rewrite=self.name_encoding) for san in nc_ext.value.excluded_subtrees or []] + return permitted, excluded, nc_ext.critical + except cryptography.x509.ExtensionNotFound: + return None, None, False + + def _get_public_key_pem(self): + return self.csr.public_key().public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo, + ) + + def _get_public_key_object(self): + return self.csr.public_key() + + def _get_subject_key_identifier(self): + try: + ext = self.csr.extensions.get_extension_for_class(x509.SubjectKeyIdentifier) + return ext.value.digest + except cryptography.x509.ExtensionNotFound: + return None + + def _get_authority_key_identifier(self): + try: + ext = self.csr.extensions.get_extension_for_class(x509.AuthorityKeyIdentifier) + issuer = None + if ext.value.authority_cert_issuer is not None: + issuer = [cryptography_decode_name(san, idn_rewrite=self.name_encoding) for san in ext.value.authority_cert_issuer] + return ext.value.key_identifier, issuer, ext.value.authority_cert_serial_number + except cryptography.x509.ExtensionNotFound: + return None, None, None + + def _get_all_extensions(self): + return cryptography_get_extensions_from_csr(self.csr) + + def _is_signature_valid(self): + return self.csr.is_signature_valid + + +def get_csr_info(module, backend, content, validate_signature=True, prefer_one_fingerprint=False): + if backend == 'cryptography': + info = CSRInfoRetrievalCryptography(module, content, validate_signature=validate_signature) + return info.get_info(prefer_one_fingerprint=prefer_one_fingerprint) + + +def select_backend(module, backend, content, validate_signature=True): + if backend == 'auto': + # Detection what is possible + can_use_cryptography = CRYPTOGRAPHY_FOUND and CRYPTOGRAPHY_VERSION >= LooseVersion(MINIMAL_CRYPTOGRAPHY_VERSION) + + # Try cryptography + if can_use_cryptography: + backend = 'cryptography' + + # Success? + if backend == 'auto': + module.fail_json(msg=("Cannot detect the required Python library " + "cryptography (>= {0})").format(MINIMAL_CRYPTOGRAPHY_VERSION)) + + if backend == 'cryptography': + if not CRYPTOGRAPHY_FOUND: + module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)), + exception=CRYPTOGRAPHY_IMP_ERR) + return backend, CSRInfoRetrievalCryptography(module, content, validate_signature=validate_signature) + else: + raise ValueError('Unsupported value for backend: {0}'.format(backend)) 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 new file mode 100644 index 00000000..dc13107b --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/privatekey.py @@ -0,0 +1,533 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2016, Yanis Guenane <yanis+ansible@guenane.org> +# 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 + + +import abc +import base64 +import traceback + +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.crypto.basic import ( + CRYPTOGRAPHY_HAS_X25519, + CRYPTOGRAPHY_HAS_X25519_FULL, + CRYPTOGRAPHY_HAS_X448, + CRYPTOGRAPHY_HAS_ED25519, + CRYPTOGRAPHY_HAS_ED448, + OpenSSLObjectError, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.support import ( + get_fingerprint_of_privatekey, +) + +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.privatekey_info import ( + PrivateKeyConsistencyError, + PrivateKeyParseError, + get_privatekey_info, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.common import ArgumentSpec + + +MINIMAL_CRYPTOGRAPHY_VERSION = '1.2.3' + +CRYPTOGRAPHY_IMP_ERR = None +try: + import cryptography + import cryptography.exceptions + import cryptography.hazmat.backends + import cryptography.hazmat.primitives.serialization + import cryptography.hazmat.primitives.asymmetric.rsa + import cryptography.hazmat.primitives.asymmetric.dsa + import cryptography.hazmat.primitives.asymmetric.ec + import cryptography.hazmat.primitives.asymmetric.utils + CRYPTOGRAPHY_VERSION = LooseVersion(cryptography.__version__) +except ImportError: + CRYPTOGRAPHY_IMP_ERR = traceback.format_exc() + CRYPTOGRAPHY_FOUND = False +else: + CRYPTOGRAPHY_FOUND = True + + +class PrivateKeyError(OpenSSLObjectError): + pass + + +# From the object called `module`, only the following properties are used: +# +# - module.params[] +# - module.warn(msg: str) +# - module.fail_json(msg: str, **kwargs) + + +@six.add_metaclass(abc.ABCMeta) +class PrivateKeyBackend: + def __init__(self, module, backend): + self.module = module + self.type = module.params['type'] + self.size = module.params['size'] + self.curve = module.params['curve'] + self.passphrase = module.params['passphrase'] + self.cipher = module.params['cipher'] + self.format = module.params['format'] + self.format_mismatch = module.params.get('format_mismatch', 'regenerate') + self.regenerate = module.params.get('regenerate', 'full_idempotence') + self.backend = backend + + self.private_key = None + + self.existing_private_key = None + self.existing_private_key_bytes = None + + self.diff_before = self._get_info(None) + self.diff_after = self._get_info(None) + + def _get_info(self, data): + if data is None: + return dict() + result = dict(can_parse_key=False) + try: + result.update(get_privatekey_info( + self.module, self.backend, data, passphrase=self.passphrase, + return_private_key_data=False, prefer_one_fingerprint=True)) + except PrivateKeyConsistencyError as exc: + result.update(exc.result) + except PrivateKeyParseError as exc: + result.update(exc.result) + except Exception as exc: + pass + return result + + @abc.abstractmethod + def generate_private_key(self): + """(Re-)Generate private key.""" + pass + + def convert_private_key(self): + """Convert existing private key (self.existing_private_key) to new private key (self.private_key). + + This is effectively a copy without active conversion. The conversion is done + during load and store; get_private_key_data() uses the destination format to + serialize the key. + """ + self._ensure_existing_private_key_loaded() + self.private_key = self.existing_private_key + + @abc.abstractmethod + def get_private_key_data(self): + """Return bytes for self.private_key.""" + pass + + def set_existing(self, privatekey_bytes): + """Set existing private key bytes. None indicates that the key does not exist.""" + self.existing_private_key_bytes = privatekey_bytes + self.diff_after = self.diff_before = self._get_info(self.existing_private_key_bytes) + + def has_existing(self): + """Query whether an existing private key is/has been there.""" + return self.existing_private_key_bytes is not None + + @abc.abstractmethod + def _check_passphrase(self): + """Check whether provided passphrase matches, assuming self.existing_private_key_bytes has been populated.""" + pass + + @abc.abstractmethod + def _ensure_existing_private_key_loaded(self): + """Make sure that self.existing_private_key is populated from self.existing_private_key_bytes.""" + pass + + @abc.abstractmethod + def _check_size_and_type(self): + """Check whether provided size and type matches, assuming self.existing_private_key has been populated.""" + pass + + @abc.abstractmethod + def _check_format(self): + """Check whether the key file format, assuming self.existing_private_key and self.existing_private_key_bytes has been populated.""" + pass + + def needs_regeneration(self): + """Check whether a regeneration is necessary.""" + if self.regenerate == 'always': + return True + if not self.has_existing(): + # key does not exist + return True + if not self._check_passphrase(): + if self.regenerate == 'full_idempotence': + return True + self.module.fail_json(msg='Unable to read the key. The key is protected with a another passphrase / no passphrase or broken.' + ' Will not proceed. To force regeneration, call the module with `generate`' + ' set to `full_idempotence` or `always`, or with `force=true`.') + self._ensure_existing_private_key_loaded() + if self.regenerate != 'never': + if not self._check_size_and_type(): + if self.regenerate in ('partial_idempotence', 'full_idempotence'): + return True + self.module.fail_json(msg='Key has wrong type and/or size.' + ' Will not proceed. To force regeneration, call the module with `generate`' + ' set to `partial_idempotence`, `full_idempotence` or `always`, or with `force=true`.') + # During generation step, regenerate if format does not match and format_mismatch == 'regenerate' + if self.format_mismatch == 'regenerate' and self.regenerate != 'never': + if not self._check_format(): + if self.regenerate in ('partial_idempotence', 'full_idempotence'): + return True + self.module.fail_json(msg='Key has wrong format.' + ' Will not proceed. To force regeneration, call the module with `generate`' + ' set to `partial_idempotence`, `full_idempotence` or `always`, or with `force=true`.' + ' To convert the key, set `format_mismatch` to `convert`.') + return False + + def needs_conversion(self): + """Check whether a conversion is necessary. Must only be called if needs_regeneration() returned False.""" + # During conversion step, convert if format does not match and format_mismatch == 'convert' + self._ensure_existing_private_key_loaded() + return self.has_existing() and self.format_mismatch == 'convert' and not self._check_format() + + def _get_fingerprint(self): + if self.private_key: + return get_fingerprint_of_privatekey(self.private_key, backend=self.backend) + try: + self._ensure_existing_private_key_loaded() + except Exception as dummy: + # Ignore errors + pass + if self.existing_private_key: + return get_fingerprint_of_privatekey(self.existing_private_key, backend=self.backend) + + def dump(self, include_key): + """Serialize the object into a dictionary.""" + + if not self.private_key: + try: + self._ensure_existing_private_key_loaded() + except Exception as dummy: + # Ignore errors + pass + result = { + 'type': self.type, + 'size': self.size, + 'fingerprint': self._get_fingerprint(), + } + if self.type == 'ECC': + result['curve'] = self.curve + # Get hold of private key bytes + pk_bytes = self.existing_private_key_bytes + if self.private_key is not None: + pk_bytes = self.get_private_key_data() + self.diff_after = self._get_info(pk_bytes) + if include_key: + # Store result + if pk_bytes: + if identify_private_key_format(pk_bytes) == 'raw': + result['privatekey'] = base64.b64encode(pk_bytes) + else: + result['privatekey'] = pk_bytes.decode('utf-8') + else: + result['privatekey'] = None + + result['diff'] = dict( + before=self.diff_before, + after=self.diff_after, + ) + return result + + +# Implementation with using cryptography +class PrivateKeyCryptographyBackend(PrivateKeyBackend): + + def _get_ec_class(self, ectype): + ecclass = cryptography.hazmat.primitives.asymmetric.ec.__dict__.get(ectype) + if ecclass is None: + self.module.fail_json(msg='Your cryptography version does not support {0}'.format(ectype)) + return ecclass + + def _add_curve(self, name, ectype, deprecated=False): + def create(size): + ecclass = self._get_ec_class(ectype) + return ecclass() + + def verify(privatekey): + ecclass = self._get_ec_class(ectype) + return isinstance(privatekey.private_numbers().public_numbers.curve, ecclass) + + self.curves[name] = { + 'create': create, + 'verify': verify, + 'deprecated': deprecated, + } + + def __init__(self, module): + super(PrivateKeyCryptographyBackend, self).__init__(module=module, backend='cryptography') + + self.curves = dict() + self._add_curve('secp224r1', 'SECP224R1') + self._add_curve('secp256k1', 'SECP256K1') + self._add_curve('secp256r1', 'SECP256R1') + self._add_curve('secp384r1', 'SECP384R1') + self._add_curve('secp521r1', 'SECP521R1') + self._add_curve('secp192r1', 'SECP192R1', deprecated=True) + self._add_curve('sect163k1', 'SECT163K1', deprecated=True) + self._add_curve('sect163r2', 'SECT163R2', deprecated=True) + self._add_curve('sect233k1', 'SECT233K1', deprecated=True) + self._add_curve('sect233r1', 'SECT233R1', deprecated=True) + self._add_curve('sect283k1', 'SECT283K1', deprecated=True) + self._add_curve('sect283r1', 'SECT283R1', deprecated=True) + self._add_curve('sect409k1', 'SECT409K1', deprecated=True) + self._add_curve('sect409r1', 'SECT409R1', deprecated=True) + self._add_curve('sect571k1', 'SECT571K1', deprecated=True) + self._add_curve('sect571r1', 'SECT571R1', deprecated=True) + self._add_curve('brainpoolP256r1', 'BrainpoolP256R1', deprecated=True) + self._add_curve('brainpoolP384r1', 'BrainpoolP384R1', deprecated=True) + self._add_curve('brainpoolP512r1', 'BrainpoolP512R1', deprecated=True) + + self.cryptography_backend = cryptography.hazmat.backends.default_backend() + + if not CRYPTOGRAPHY_HAS_X25519 and self.type == 'X25519': + self.module.fail_json(msg='Your cryptography version does not support X25519') + if not CRYPTOGRAPHY_HAS_X25519_FULL and self.type == 'X25519': + self.module.fail_json(msg='Your cryptography version does not support X25519 serialization') + if not CRYPTOGRAPHY_HAS_X448 and self.type == 'X448': + self.module.fail_json(msg='Your cryptography version does not support X448') + if not CRYPTOGRAPHY_HAS_ED25519 and self.type == 'Ed25519': + self.module.fail_json(msg='Your cryptography version does not support Ed25519') + if not CRYPTOGRAPHY_HAS_ED448 and self.type == 'Ed448': + self.module.fail_json(msg='Your cryptography version does not support Ed448') + + def _get_wanted_format(self): + if self.format not in ('auto', 'auto_ignore'): + return self.format + if self.type in ('X25519', 'X448', 'Ed25519', 'Ed448'): + return 'pkcs8' + else: + return 'pkcs1' + + def generate_private_key(self): + """(Re-)Generate private key.""" + try: + if self.type == 'RSA': + self.private_key = cryptography.hazmat.primitives.asymmetric.rsa.generate_private_key( + public_exponent=65537, # OpenSSL always uses this + key_size=self.size, + backend=self.cryptography_backend + ) + if self.type == 'DSA': + self.private_key = cryptography.hazmat.primitives.asymmetric.dsa.generate_private_key( + key_size=self.size, + backend=self.cryptography_backend + ) + if CRYPTOGRAPHY_HAS_X25519_FULL and self.type == 'X25519': + self.private_key = cryptography.hazmat.primitives.asymmetric.x25519.X25519PrivateKey.generate() + if CRYPTOGRAPHY_HAS_X448 and self.type == 'X448': + self.private_key = cryptography.hazmat.primitives.asymmetric.x448.X448PrivateKey.generate() + if CRYPTOGRAPHY_HAS_ED25519 and self.type == 'Ed25519': + self.private_key = cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey.generate() + if CRYPTOGRAPHY_HAS_ED448 and self.type == 'Ed448': + self.private_key = cryptography.hazmat.primitives.asymmetric.ed448.Ed448PrivateKey.generate() + if self.type == 'ECC' and self.curve in self.curves: + if self.curves[self.curve]['deprecated']: + self.module.warn('Elliptic curves of type {0} should not be used for new keys!'.format(self.curve)) + self.private_key = cryptography.hazmat.primitives.asymmetric.ec.generate_private_key( + curve=self.curves[self.curve]['create'](self.size), + backend=self.cryptography_backend + ) + except cryptography.exceptions.UnsupportedAlgorithm as dummy: + self.module.fail_json(msg='Cryptography backend does not support the algorithm required for {0}'.format(self.type)) + + def get_private_key_data(self): + """Return bytes for self.private_key""" + # Select export format and encoding + try: + export_format = self._get_wanted_format() + export_encoding = cryptography.hazmat.primitives.serialization.Encoding.PEM + if export_format == 'pkcs1': + # "TraditionalOpenSSL" format is PKCS1 + export_format = cryptography.hazmat.primitives.serialization.PrivateFormat.TraditionalOpenSSL + elif export_format == 'pkcs8': + export_format = cryptography.hazmat.primitives.serialization.PrivateFormat.PKCS8 + elif export_format == 'raw': + export_format = cryptography.hazmat.primitives.serialization.PrivateFormat.Raw + export_encoding = cryptography.hazmat.primitives.serialization.Encoding.Raw + except AttributeError: + self.module.fail_json(msg='Cryptography backend does not support the selected output format "{0}"'.format(self.format)) + + # Select key encryption + encryption_algorithm = cryptography.hazmat.primitives.serialization.NoEncryption() + if self.cipher and self.passphrase: + if self.cipher == 'auto': + encryption_algorithm = cryptography.hazmat.primitives.serialization.BestAvailableEncryption(to_bytes(self.passphrase)) + else: + self.module.fail_json(msg='Cryptography backend can only use "auto" for cipher option.') + + # Serialize key + try: + return self.private_key.private_bytes( + encoding=export_encoding, + format=export_format, + encryption_algorithm=encryption_algorithm + ) + except ValueError as dummy: + self.module.fail_json( + msg='Cryptography backend cannot serialize the private key in the required format "{0}"'.format(self.format) + ) + except Exception as dummy: + self.module.fail_json( + msg='Error while serializing the private key in the required format "{0}"'.format(self.format), + exception=traceback.format_exc() + ) + + def _load_privatekey(self): + data = self.existing_private_key_bytes + try: + # Interpret bytes depending on format. + format = identify_private_key_format(data) + if format == 'raw': + if len(data) == 56 and CRYPTOGRAPHY_HAS_X448: + return cryptography.hazmat.primitives.asymmetric.x448.X448PrivateKey.from_private_bytes(data) + if len(data) == 57 and CRYPTOGRAPHY_HAS_ED448: + return cryptography.hazmat.primitives.asymmetric.ed448.Ed448PrivateKey.from_private_bytes(data) + if len(data) == 32: + if CRYPTOGRAPHY_HAS_X25519 and (self.type == 'X25519' or not CRYPTOGRAPHY_HAS_ED25519): + return cryptography.hazmat.primitives.asymmetric.x25519.X25519PrivateKey.from_private_bytes(data) + if CRYPTOGRAPHY_HAS_ED25519 and (self.type == 'Ed25519' or not CRYPTOGRAPHY_HAS_X25519): + return cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey.from_private_bytes(data) + if CRYPTOGRAPHY_HAS_X25519 and CRYPTOGRAPHY_HAS_ED25519: + try: + return cryptography.hazmat.primitives.asymmetric.x25519.X25519PrivateKey.from_private_bytes(data) + except Exception: + return cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey.from_private_bytes(data) + raise PrivateKeyError('Cannot load raw key') + else: + return cryptography.hazmat.primitives.serialization.load_pem_private_key( + data, + None if self.passphrase is None else to_bytes(self.passphrase), + backend=self.cryptography_backend + ) + except Exception as e: + raise PrivateKeyError(e) + + def _ensure_existing_private_key_loaded(self): + if self.existing_private_key is None and self.has_existing(): + self.existing_private_key = self._load_privatekey() + + def _check_passphrase(self): + try: + format = identify_private_key_format(self.existing_private_key_bytes) + if format == 'raw': + # Raw keys cannot be encrypted. To avoid incompatibilities, we try to + # actually load the key (and return False when this fails). + self._load_privatekey() + # Loading the key succeeded. Only return True when no passphrase was + # provided. + return self.passphrase is None + else: + return cryptography.hazmat.primitives.serialization.load_pem_private_key( + self.existing_private_key_bytes, + None if self.passphrase is None else to_bytes(self.passphrase), + backend=self.cryptography_backend + ) + except Exception as dummy: + return False + + def _check_size_and_type(self): + if isinstance(self.existing_private_key, cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey): + return self.type == 'RSA' and self.size == self.existing_private_key.key_size + if isinstance(self.existing_private_key, cryptography.hazmat.primitives.asymmetric.dsa.DSAPrivateKey): + return self.type == 'DSA' and self.size == self.existing_private_key.key_size + if CRYPTOGRAPHY_HAS_X25519 and isinstance(self.existing_private_key, cryptography.hazmat.primitives.asymmetric.x25519.X25519PrivateKey): + return self.type == 'X25519' + if CRYPTOGRAPHY_HAS_X448 and isinstance(self.existing_private_key, cryptography.hazmat.primitives.asymmetric.x448.X448PrivateKey): + return self.type == 'X448' + if CRYPTOGRAPHY_HAS_ED25519 and isinstance(self.existing_private_key, cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey): + return self.type == 'Ed25519' + if CRYPTOGRAPHY_HAS_ED448 and isinstance(self.existing_private_key, cryptography.hazmat.primitives.asymmetric.ed448.Ed448PrivateKey): + return self.type == 'Ed448' + if isinstance(self.existing_private_key, cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey): + if self.type != 'ECC': + return False + if self.curve not in self.curves: + return False + return self.curves[self.curve]['verify'](self.existing_private_key) + + return False + + def _check_format(self): + if self.format == 'auto_ignore': + return True + try: + format = identify_private_key_format(self.existing_private_key_bytes) + return format == self._get_wanted_format() + except Exception as dummy: + return False + + +def select_backend(module, backend): + if backend == 'auto': + # Detection what is possible + can_use_cryptography = CRYPTOGRAPHY_FOUND and CRYPTOGRAPHY_VERSION >= LooseVersion(MINIMAL_CRYPTOGRAPHY_VERSION) + + # Decision + if can_use_cryptography: + backend = 'cryptography' + + # Success? + if backend == 'auto': + module.fail_json(msg=("Cannot detect the required Python library " + "cryptography (>= {0})").format(MINIMAL_CRYPTOGRAPHY_VERSION)) + if backend == 'cryptography': + if not CRYPTOGRAPHY_FOUND: + module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)), + exception=CRYPTOGRAPHY_IMP_ERR) + return backend, PrivateKeyCryptographyBackend(module) + else: + raise Exception('Unsupported value for backend: {0}'.format(backend)) + + +def get_privatekey_argument_spec(): + return ArgumentSpec( + argument_spec=dict( + size=dict(type='int', default=4096), + type=dict(type='str', default='RSA', choices=[ + 'DSA', 'ECC', 'Ed25519', 'Ed448', 'RSA', 'X25519', 'X448' + ]), + curve=dict(type='str', choices=[ + 'secp224r1', 'secp256k1', 'secp256r1', 'secp384r1', 'secp521r1', + 'secp192r1', 'brainpoolP256r1', 'brainpoolP384r1', 'brainpoolP512r1', + 'sect163k1', 'sect163r2', 'sect233k1', 'sect233r1', 'sect283k1', + 'sect283r1', 'sect409k1', 'sect409r1', 'sect571k1', 'sect571r1', + ]), + passphrase=dict(type='str', no_log=True), + cipher=dict(type='str'), + format=dict(type='str', default='auto_ignore', choices=['pkcs1', 'pkcs8', 'raw', 'auto', 'auto_ignore']), + format_mismatch=dict(type='str', default='regenerate', choices=['regenerate', 'convert']), + select_crypto_backend=dict(type='str', choices=['auto', 'cryptography'], default='auto'), + regenerate=dict( + type='str', + default='full_idempotence', + choices=['never', 'fail', 'partial_idempotence', 'full_idempotence', 'always'] + ), + ), + required_together=[ + ['cipher', 'passphrase'] + ], + required_if=[ + ['type', 'ECC', ['curve']], + ], + ) 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 new file mode 100644 index 00000000..905ca70f --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/privatekey_convert.py @@ -0,0 +1,236 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2022, 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 abc +import traceback + +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.io import ( + load_file, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import ( + CRYPTOGRAPHY_HAS_X25519, + CRYPTOGRAPHY_HAS_X448, + CRYPTOGRAPHY_HAS_ED25519, + CRYPTOGRAPHY_HAS_ED448, + OpenSSLObjectError, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_support import ( + cryptography_compare_private_keys, +) + +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' + +CRYPTOGRAPHY_IMP_ERR = None +try: + import cryptography + import cryptography.exceptions + import cryptography.hazmat.backends + import cryptography.hazmat.primitives.serialization + import cryptography.hazmat.primitives.asymmetric.rsa + import cryptography.hazmat.primitives.asymmetric.dsa + import cryptography.hazmat.primitives.asymmetric.ec + import cryptography.hazmat.primitives.asymmetric.utils + CRYPTOGRAPHY_VERSION = LooseVersion(cryptography.__version__) +except ImportError: + CRYPTOGRAPHY_IMP_ERR = traceback.format_exc() + CRYPTOGRAPHY_FOUND = False +else: + CRYPTOGRAPHY_FOUND = True + + +class PrivateKeyError(OpenSSLObjectError): + pass + + +# From the object called `module`, only the following properties are used: +# +# - module.params[] +# - module.warn(msg: str) +# - module.fail_json(msg: str, **kwargs) + + +@six.add_metaclass(abc.ABCMeta) +class PrivateKeyConvertBackend: + def __init__(self, module, backend): + self.module = module + self.src_path = module.params['src_path'] + self.src_content = module.params['src_content'] + self.src_passphrase = module.params['src_passphrase'] + self.format = module.params['format'] + self.dest_passphrase = module.params['dest_passphrase'] + self.backend = backend + + self.src_private_key = None + if self.src_path is not None: + self.src_private_key_bytes = load_file(self.src_path, module) + else: + self.src_private_key_bytes = self.src_content.encode('utf-8') + + self.dest_private_key = None + self.dest_private_key_bytes = None + + @abc.abstractmethod + def get_private_key_data(self): + """Return bytes for self.src_private_key in output format.""" + pass + + def set_existing_destination(self, privatekey_bytes): + """Set existing private key bytes. None indicates that the key does not exist.""" + self.dest_private_key_bytes = privatekey_bytes + + def has_existing_destination(self): + """Query whether an existing private key is/has been there.""" + return self.dest_private_key_bytes is not None + + @abc.abstractmethod + def _load_private_key(self, data, passphrase, current_hint=None): + """Check whether data cna be loaded as a private key with the provided passphrase. Return tuple (type, private_key).""" + pass + + def needs_conversion(self): + """Check whether a conversion is necessary. Must only be called if needs_regeneration() returned False.""" + dummy, self.src_private_key = self._load_private_key(self.src_private_key_bytes, self.src_passphrase) + + if not self.has_existing_destination(): + return True + + try: + format, self.dest_private_key = self._load_private_key(self.dest_private_key_bytes, self.dest_passphrase, current_hint=self.src_private_key) + except Exception: + return True + + return format != self.format or not cryptography_compare_private_keys(self.dest_private_key, self.src_private_key) + + def dump(self): + """Serialize the object into a dictionary.""" + return {} + + +# Implementation with using cryptography +class PrivateKeyConvertCryptographyBackend(PrivateKeyConvertBackend): + def __init__(self, module): + super(PrivateKeyConvertCryptographyBackend, self).__init__(module=module, backend='cryptography') + + self.cryptography_backend = cryptography.hazmat.backends.default_backend() + + def get_private_key_data(self): + """Return bytes for self.src_private_key in output format""" + # Select export format and encoding + try: + export_encoding = cryptography.hazmat.primitives.serialization.Encoding.PEM + if self.format == 'pkcs1': + # "TraditionalOpenSSL" format is PKCS1 + export_format = cryptography.hazmat.primitives.serialization.PrivateFormat.TraditionalOpenSSL + elif self.format == 'pkcs8': + export_format = cryptography.hazmat.primitives.serialization.PrivateFormat.PKCS8 + elif self.format == 'raw': + export_format = cryptography.hazmat.primitives.serialization.PrivateFormat.Raw + export_encoding = cryptography.hazmat.primitives.serialization.Encoding.Raw + except AttributeError: + self.module.fail_json(msg='Cryptography backend does not support the selected output format "{0}"'.format(self.format)) + + # Select key encryption + encryption_algorithm = cryptography.hazmat.primitives.serialization.NoEncryption() + if self.dest_passphrase: + encryption_algorithm = cryptography.hazmat.primitives.serialization.BestAvailableEncryption(to_bytes(self.dest_passphrase)) + + # Serialize key + try: + return self.src_private_key.private_bytes( + encoding=export_encoding, + format=export_format, + encryption_algorithm=encryption_algorithm + ) + except ValueError as dummy: + self.module.fail_json( + msg='Cryptography backend cannot serialize the private key in the required format "{0}"'.format(self.format) + ) + except Exception as dummy: + self.module.fail_json( + msg='Error while serializing the private key in the required format "{0}"'.format(self.format), + exception=traceback.format_exc() + ) + + def _load_private_key(self, data, passphrase, current_hint=None): + try: + # Interpret bytes depending on format. + format = identify_private_key_format(data) + if format == 'raw': + if passphrase is not None: + raise PrivateKeyError('Cannot load raw key with passphrase') + if len(data) == 56 and CRYPTOGRAPHY_HAS_X448: + return format, cryptography.hazmat.primitives.asymmetric.x448.X448PrivateKey.from_private_bytes(data) + if len(data) == 57 and CRYPTOGRAPHY_HAS_ED448: + return format, cryptography.hazmat.primitives.asymmetric.ed448.Ed448PrivateKey.from_private_bytes(data) + if len(data) == 32: + if CRYPTOGRAPHY_HAS_X25519 and not CRYPTOGRAPHY_HAS_ED25519: + return format, cryptography.hazmat.primitives.asymmetric.x25519.X25519PrivateKey.from_private_bytes(data) + if CRYPTOGRAPHY_HAS_ED25519 and not CRYPTOGRAPHY_HAS_X25519: + return format, cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey.from_private_bytes(data) + if CRYPTOGRAPHY_HAS_X25519 and CRYPTOGRAPHY_HAS_ED25519: + if isinstance(current_hint, cryptography.hazmat.primitives.asymmetric.x25519.X25519PrivateKey): + try: + return format, cryptography.hazmat.primitives.asymmetric.x25519.X25519PrivateKey.from_private_bytes(data) + except Exception: + return format, cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey.from_private_bytes(data) + else: + try: + return format, cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey.from_private_bytes(data) + except Exception: + return format, cryptography.hazmat.primitives.asymmetric.x25519.X25519PrivateKey.from_private_bytes(data) + raise PrivateKeyError('Cannot load raw key') + else: + return format, cryptography.hazmat.primitives.serialization.load_pem_private_key( + data, + None if passphrase is None else to_bytes(passphrase), + backend=self.cryptography_backend + ) + except Exception as e: + raise PrivateKeyError(e) + + +def select_backend(module): + if not CRYPTOGRAPHY_FOUND: + module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)), + exception=CRYPTOGRAPHY_IMP_ERR) + return PrivateKeyConvertCryptographyBackend(module) + + +def get_privatekey_argument_spec(): + return ArgumentSpec( + argument_spec=dict( + src_path=dict(type='path'), + src_content=dict(type='str'), + src_passphrase=dict(type='str', no_log=True), + dest_passphrase=dict(type='str', no_log=True), + format=dict(type='str', required=True, choices=['pkcs1', 'pkcs8', 'raw']), + ), + mutually_exclusive=[ + ['src_path', 'src_content'], + ], + required_one_of=[ + ['src_path', 'src_content'], + ], + ) diff --git a/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/privatekey_info.py b/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/privatekey_info.py new file mode 100644 index 00000000..d87b9c2b --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/privatekey_info.py @@ -0,0 +1,287 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2016-2017, Yanis Guenane <yanis+ansible@guenane.org> +# Copyright (c) 2017, Markus Teufelberger <mteufelberger+ansible@mgit.at> +# 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 + + +import abc +import traceback + +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_bytes + +from ansible_collections.community.crypto.plugins.module_utils.version import LooseVersion + +from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import ( + CRYPTOGRAPHY_HAS_ED25519, + CRYPTOGRAPHY_HAS_ED448, + OpenSSLObjectError, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.support import ( + load_privatekey, + get_fingerprint_of_bytes, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.math import ( + binary_exp_mod, + quick_is_not_prime, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.publickey_info import ( + _get_cryptography_public_key_info, +) + + +MINIMAL_CRYPTOGRAPHY_VERSION = '1.2.3' + +CRYPTOGRAPHY_IMP_ERR = None +try: + import cryptography + from cryptography.hazmat.primitives import serialization + CRYPTOGRAPHY_VERSION = LooseVersion(cryptography.__version__) +except ImportError: + CRYPTOGRAPHY_IMP_ERR = traceback.format_exc() + CRYPTOGRAPHY_FOUND = False +else: + CRYPTOGRAPHY_FOUND = True + +SIGNATURE_TEST_DATA = b'1234' + + +def _get_cryptography_private_key_info(key, need_private_key_data=False): + key_type, key_public_data = _get_cryptography_public_key_info(key.public_key()) + key_private_data = dict() + if need_private_key_data: + if isinstance(key, cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey): + private_numbers = key.private_numbers() + key_private_data['p'] = private_numbers.p + key_private_data['q'] = private_numbers.q + key_private_data['exponent'] = private_numbers.d + elif isinstance(key, cryptography.hazmat.primitives.asymmetric.dsa.DSAPrivateKey): + private_numbers = key.private_numbers() + key_private_data['x'] = private_numbers.x + elif isinstance(key, cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey): + private_numbers = key.private_numbers() + key_private_data['multiplier'] = private_numbers.private_value + return key_type, key_public_data, key_private_data + + +def _check_dsa_consistency(key_public_data, key_private_data): + # Get parameters + p = key_public_data.get('p') + q = key_public_data.get('q') + g = key_public_data.get('g') + y = key_public_data.get('y') + x = key_private_data.get('x') + for v in (p, q, g, y, x): + if v is None: + return None + # Make sure that g is not 0, 1 or -1 in Z/pZ + if g < 2 or g >= p - 1: + return False + # Make sure that x is in range + if x < 1 or x >= q: + return False + # Check whether q divides p-1 + if (p - 1) % q != 0: + return False + # Check that g**q mod p == 1 + if binary_exp_mod(g, q, p) != 1: + return False + # Check whether g**x mod p == y + if binary_exp_mod(g, x, p) != y: + return False + # Check (quickly) whether p or q are not primes + if quick_is_not_prime(q) or quick_is_not_prime(p): + return False + return True + + +def _is_cryptography_key_consistent(key, key_public_data, key_private_data): + if isinstance(key, cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey): + return bool(key._backend._lib.RSA_check_key(key._rsa_cdata)) + if isinstance(key, cryptography.hazmat.primitives.asymmetric.dsa.DSAPrivateKey): + result = _check_dsa_consistency(key_public_data, key_private_data) + if result is not None: + return result + try: + signature = key.sign(SIGNATURE_TEST_DATA, cryptography.hazmat.primitives.hashes.SHA256()) + except AttributeError: + # sign() was added in cryptography 1.5, but we support older versions + return None + try: + key.public_key().verify( + signature, + SIGNATURE_TEST_DATA, + cryptography.hazmat.primitives.hashes.SHA256() + ) + return True + except cryptography.exceptions.InvalidSignature: + return False + if isinstance(key, cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey): + try: + signature = key.sign( + SIGNATURE_TEST_DATA, + cryptography.hazmat.primitives.asymmetric.ec.ECDSA(cryptography.hazmat.primitives.hashes.SHA256()) + ) + except AttributeError: + # sign() was added in cryptography 1.5, but we support older versions + return None + try: + key.public_key().verify( + signature, + SIGNATURE_TEST_DATA, + cryptography.hazmat.primitives.asymmetric.ec.ECDSA(cryptography.hazmat.primitives.hashes.SHA256()) + ) + return True + except cryptography.exceptions.InvalidSignature: + return False + has_simple_sign_function = False + if CRYPTOGRAPHY_HAS_ED25519 and isinstance(key, cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey): + has_simple_sign_function = True + if CRYPTOGRAPHY_HAS_ED448 and isinstance(key, cryptography.hazmat.primitives.asymmetric.ed448.Ed448PrivateKey): + has_simple_sign_function = True + if has_simple_sign_function: + signature = key.sign(SIGNATURE_TEST_DATA) + try: + key.public_key().verify(signature, SIGNATURE_TEST_DATA) + return True + except cryptography.exceptions.InvalidSignature: + return False + # For X25519 and X448, there's no test yet. + return None + + +class PrivateKeyConsistencyError(OpenSSLObjectError): + def __init__(self, msg, result): + super(PrivateKeyConsistencyError, self).__init__(msg) + self.error_message = msg + self.result = result + + +class PrivateKeyParseError(OpenSSLObjectError): + def __init__(self, msg, result): + super(PrivateKeyParseError, self).__init__(msg) + self.error_message = msg + self.result = result + + +@six.add_metaclass(abc.ABCMeta) +class PrivateKeyInfoRetrieval(object): + def __init__(self, module, backend, content, passphrase=None, return_private_key_data=False, check_consistency=False): + # content must be a bytes string + self.module = module + self.backend = backend + self.content = content + self.passphrase = passphrase + self.return_private_key_data = return_private_key_data + self.check_consistency = check_consistency + + @abc.abstractmethod + def _get_public_key(self, binary): + pass + + @abc.abstractmethod + def _get_key_info(self, need_private_key_data=False): + pass + + @abc.abstractmethod + def _is_key_consistent(self, key_public_data, key_private_data): + pass + + def get_info(self, prefer_one_fingerprint=False): + result = dict( + can_parse_key=False, + key_is_consistent=None, + ) + priv_key_detail = self.content + try: + self.key = load_privatekey( + path=None, + content=priv_key_detail, + passphrase=to_bytes(self.passphrase) if self.passphrase is not None else self.passphrase, + backend=self.backend + ) + result['can_parse_key'] = True + except OpenSSLObjectError as exc: + raise PrivateKeyParseError(to_native(exc), result) + + result['public_key'] = to_native(self._get_public_key(binary=False)) + pk = self._get_public_key(binary=True) + result['public_key_fingerprints'] = get_fingerprint_of_bytes( + pk, prefer_one=prefer_one_fingerprint) if pk is not None else dict() + + key_type, key_public_data, key_private_data = self._get_key_info( + need_private_key_data=self.return_private_key_data or self.check_consistency) + result['type'] = key_type + result['public_data'] = key_public_data + if self.return_private_key_data: + result['private_data'] = key_private_data + + if self.check_consistency: + result['key_is_consistent'] = self._is_key_consistent(key_public_data, key_private_data) + if result['key_is_consistent'] is False: + # Only fail when it is False, to avoid to fail on None (which means "we do not know") + msg = ( + "Private key is not consistent! (See " + "https://blog.hboeck.de/archives/888-How-I-tricked-Symantec-with-a-Fake-Private-Key.html)" + ) + raise PrivateKeyConsistencyError(msg, result) + return result + + +class PrivateKeyInfoRetrievalCryptography(PrivateKeyInfoRetrieval): + """Validate the supplied private key, using the cryptography backend""" + def __init__(self, module, content, **kwargs): + super(PrivateKeyInfoRetrievalCryptography, self).__init__(module, 'cryptography', content, **kwargs) + + def _get_public_key(self, binary): + return self.key.public_key().public_bytes( + serialization.Encoding.DER if binary else serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo + ) + + def _get_key_info(self, need_private_key_data=False): + return _get_cryptography_private_key_info(self.key, need_private_key_data=need_private_key_data) + + def _is_key_consistent(self, key_public_data, key_private_data): + return _is_cryptography_key_consistent(self.key, key_public_data, key_private_data) + + +def get_privatekey_info(module, backend, content, passphrase=None, return_private_key_data=False, prefer_one_fingerprint=False): + if backend == 'cryptography': + info = PrivateKeyInfoRetrievalCryptography( + module, content, passphrase=passphrase, return_private_key_data=return_private_key_data) + return info.get_info(prefer_one_fingerprint=prefer_one_fingerprint) + + +def select_backend(module, backend, content, passphrase=None, return_private_key_data=False, check_consistency=False): + if backend == 'auto': + # Detection what is possible + can_use_cryptography = CRYPTOGRAPHY_FOUND and CRYPTOGRAPHY_VERSION >= LooseVersion(MINIMAL_CRYPTOGRAPHY_VERSION) + + # Try cryptography + if can_use_cryptography: + backend = 'cryptography' + + # Success? + if backend == 'auto': + module.fail_json(msg=("Cannot detect the required Python library " + "cryptography (>= {0})").format(MINIMAL_CRYPTOGRAPHY_VERSION)) + + if backend == 'cryptography': + if not CRYPTOGRAPHY_FOUND: + module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)), + exception=CRYPTOGRAPHY_IMP_ERR) + return backend, PrivateKeyInfoRetrievalCryptography( + module, content, passphrase=passphrase, return_private_key_data=return_private_key_data, check_consistency=check_consistency) + else: + raise ValueError('Unsupported value for backend: {0}'.format(backend)) diff --git a/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/publickey_info.py b/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/publickey_info.py new file mode 100644 index 00000000..d381d206 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/publickey_info.py @@ -0,0 +1,168 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2020-2021, 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 abc +import traceback + +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 + +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, + CRYPTOGRAPHY_HAS_ED25519, + CRYPTOGRAPHY_HAS_ED448, + OpenSSLObjectError, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.support import ( + get_fingerprint_of_bytes, + load_publickey, +) + + +MINIMAL_CRYPTOGRAPHY_VERSION = '1.2.3' + +CRYPTOGRAPHY_IMP_ERR = None +try: + import cryptography + from cryptography.hazmat.primitives import serialization + CRYPTOGRAPHY_VERSION = LooseVersion(cryptography.__version__) +except ImportError: + CRYPTOGRAPHY_IMP_ERR = traceback.format_exc() + CRYPTOGRAPHY_FOUND = False +else: + CRYPTOGRAPHY_FOUND = True + + +def _get_cryptography_public_key_info(key): + key_public_data = dict() + if isinstance(key, cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey): + key_type = 'RSA' + public_numbers = key.public_numbers() + key_public_data['size'] = key.key_size + key_public_data['modulus'] = public_numbers.n + key_public_data['exponent'] = public_numbers.e + elif isinstance(key, cryptography.hazmat.primitives.asymmetric.dsa.DSAPublicKey): + key_type = 'DSA' + parameter_numbers = key.parameters().parameter_numbers() + public_numbers = key.public_numbers() + key_public_data['size'] = key.key_size + key_public_data['p'] = parameter_numbers.p + key_public_data['q'] = parameter_numbers.q + key_public_data['g'] = parameter_numbers.g + key_public_data['y'] = public_numbers.y + elif CRYPTOGRAPHY_HAS_X25519 and isinstance(key, cryptography.hazmat.primitives.asymmetric.x25519.X25519PublicKey): + key_type = 'X25519' + elif CRYPTOGRAPHY_HAS_X448 and isinstance(key, cryptography.hazmat.primitives.asymmetric.x448.X448PublicKey): + key_type = 'X448' + elif CRYPTOGRAPHY_HAS_ED25519 and isinstance(key, cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PublicKey): + key_type = 'Ed25519' + elif CRYPTOGRAPHY_HAS_ED448 and isinstance(key, cryptography.hazmat.primitives.asymmetric.ed448.Ed448PublicKey): + key_type = 'Ed448' + elif isinstance(key, cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePublicKey): + key_type = 'ECC' + public_numbers = key.public_numbers() + key_public_data['curve'] = key.curve.name + key_public_data['x'] = public_numbers.x + key_public_data['y'] = public_numbers.y + key_public_data['exponent_size'] = key.curve.key_size + else: + key_type = 'unknown ({0})'.format(type(key)) + return key_type, key_public_data + + +class PublicKeyParseError(OpenSSLObjectError): + def __init__(self, msg, result): + super(PublicKeyParseError, self).__init__(msg) + self.error_message = msg + self.result = result + + +@six.add_metaclass(abc.ABCMeta) +class PublicKeyInfoRetrieval(object): + def __init__(self, module, backend, content=None, key=None): + # content must be a bytes string + self.module = module + self.backend = backend + self.content = content + self.key = key + + @abc.abstractmethod + def _get_public_key(self, binary): + pass + + @abc.abstractmethod + def _get_key_info(self): + pass + + def get_info(self, prefer_one_fingerprint=False): + result = dict() + if self.key is None: + try: + self.key = load_publickey(content=self.content, backend=self.backend) + except OpenSSLObjectError as e: + raise PublicKeyParseError(to_native(e), {}) + + pk = self._get_public_key(binary=True) + result['fingerprints'] = get_fingerprint_of_bytes( + pk, prefer_one=prefer_one_fingerprint) if pk is not None else dict() + + key_type, key_public_data = self._get_key_info() + result['type'] = key_type + result['public_data'] = key_public_data + return result + + +class PublicKeyInfoRetrievalCryptography(PublicKeyInfoRetrieval): + """Validate the supplied public key, using the cryptography backend""" + def __init__(self, module, content=None, key=None): + super(PublicKeyInfoRetrievalCryptography, self).__init__(module, 'cryptography', content=content, key=key) + + def _get_public_key(self, binary): + return self.key.public_bytes( + serialization.Encoding.DER if binary else serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo + ) + + def _get_key_info(self): + return _get_cryptography_public_key_info(self.key) + + +def get_publickey_info(module, backend, content=None, key=None, prefer_one_fingerprint=False): + if backend == 'cryptography': + info = PublicKeyInfoRetrievalCryptography(module, content=content, key=key) + return info.get_info(prefer_one_fingerprint=prefer_one_fingerprint) + + +def select_backend(module, backend, content=None, key=None): + if backend == 'auto': + # Detection what is possible + can_use_cryptography = CRYPTOGRAPHY_FOUND and CRYPTOGRAPHY_VERSION >= LooseVersion(MINIMAL_CRYPTOGRAPHY_VERSION) + + # Try cryptography + if can_use_cryptography: + backend = 'cryptography' + + # Success? + if backend == 'auto': + module.fail_json(msg=("Cannot detect any of the required Python libraries " + "cryptography (>= {0})").format(MINIMAL_CRYPTOGRAPHY_VERSION)) + + if backend == 'cryptography': + if not CRYPTOGRAPHY_FOUND: + module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)), + exception=CRYPTOGRAPHY_IMP_ERR) + return backend, PublicKeyInfoRetrievalCryptography(module, content=content, key=key) + else: + raise ValueError('Unsupported value for backend: {0}'.format(backend)) diff --git a/ansible_collections/community/crypto/plugins/module_utils/crypto/openssh.py b/ansible_collections/community/crypto/plugins/module_utils/crypto/openssh.py new file mode 100644 index 00000000..98247538 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/crypto/openssh.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2020, Doug Stanley <doug+ansible@technologixllc.com> +# 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 + +# This import is only to maintain backwards compatibility +from ansible_collections.community.crypto.plugins.module_utils.openssh.utils import ( # noqa: F401, pylint: disable=unused-import + parse_openssh_version +) diff --git a/ansible_collections/community/crypto/plugins/module_utils/crypto/pem.py b/ansible_collections/community/crypto/plugins/module_utils/crypto/pem.py new file mode 100644 index 00000000..4dc9745f --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/crypto/pem.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2019, 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 + + +PEM_START = '-----BEGIN ' +PEM_END = '-----' +PKCS8_PRIVATEKEY_NAMES = ('PRIVATE KEY', 'ENCRYPTED PRIVATE KEY') +PKCS1_PRIVATEKEY_SUFFIX = ' PRIVATE KEY' + + +def identify_pem_format(content): + '''Given the contents of a binary file, tests whether this could be a PEM file.''' + try: + lines = content.decode('utf-8').splitlines(False) + if lines[0].startswith(PEM_START) and lines[0].endswith(PEM_END) and len(lines[0]) > len(PEM_START) + len(PEM_END): + return True + except UnicodeDecodeError: + pass + return False + + +def identify_private_key_format(content): + '''Given the contents of a private key file, identifies its format.''' + # See https://github.com/openssl/openssl/blob/master/crypto/pem/pem_pkey.c#L40-L85 + # (PEM_read_bio_PrivateKey) + # and https://github.com/openssl/openssl/blob/master/include/openssl/pem.h#L46-L47 + # (PEM_STRING_PKCS8, PEM_STRING_PKCS8INF) + try: + lines = content.decode('utf-8').splitlines(False) + if lines[0].startswith(PEM_START) and lines[0].endswith(PEM_END) and len(lines[0]) > len(PEM_START) + len(PEM_END): + name = lines[0][len(PEM_START):-len(PEM_END)] + if name in PKCS8_PRIVATEKEY_NAMES: + return 'pkcs8' + if len(name) > len(PKCS1_PRIVATEKEY_SUFFIX) and name.endswith(PKCS1_PRIVATEKEY_SUFFIX): + return 'pkcs1' + return 'unknown-pem' + except UnicodeDecodeError: + pass + return 'raw' + + +def split_pem_list(text, keep_inbetween=False): + ''' + Split concatenated PEM objects into a list of strings, where each is one PEM object. + ''' + result = [] + current = [] if keep_inbetween else None + for line in text.splitlines(True): + if line.strip(): + if not keep_inbetween and line.startswith('-----BEGIN '): + current = [] + if current is not None: + current.append(line) + if line.startswith('-----END '): + result.append(''.join(current)) + current = [] if keep_inbetween else None + return result + + +def extract_first_pem(text): + ''' + Given one PEM or multiple concatenated PEM objects, return only the first one, or None if there is none. + ''' + all_pems = split_pem_list(text) + if not all_pems: + return None + return all_pems[0] diff --git a/ansible_collections/community/crypto/plugins/module_utils/crypto/support.py b/ansible_collections/community/crypto/plugins/module_utils/crypto/support.py new file mode 100644 index 00000000..ad8f1610 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/crypto/support.py @@ -0,0 +1,401 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2016, Yanis Guenane <yanis+ansible@guenane.org> +# 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 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 + +try: + from OpenSSL import crypto + HAS_PYOPENSSL = True +except (ImportError, AttributeError): + # Error handled in the calling module. + HAS_PYOPENSSL = False + +try: + from cryptography import x509 + from cryptography.hazmat.backends import default_backend as cryptography_backend + from cryptography.hazmat.primitives.serialization import load_pem_private_key + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives import serialization +except ImportError: + # Error handled in the calling module. + pass + +from .basic import ( + OpenSSLObjectError, + OpenSSLBadPassphraseError, +) + + +# This list of preferred fingerprints is used when prefer_one=True is supplied to the +# fingerprinting methods. +PREFERRED_FINGERPRINTS = ( + 'sha256', 'sha3_256', 'sha512', 'sha3_512', 'sha384', 'sha3_384', 'sha1', 'md5' +) + + +def get_fingerprint_of_bytes(source, prefer_one=False): + """Generate the fingerprint of the given bytes.""" + + fingerprint = {} + + try: + algorithms = hashlib.algorithms + except AttributeError: + try: + algorithms = hashlib.algorithms_guaranteed + except AttributeError: + return None + + if prefer_one: + # Sort algorithms to have the ones in PREFERRED_FINGERPRINTS at the beginning + prefered_algorithms = [algorithm for algorithm in PREFERRED_FINGERPRINTS if algorithm in algorithms] + prefered_algorithms += sorted([algorithm for algorithm in algorithms if algorithm not in PREFERRED_FINGERPRINTS]) + algorithms = prefered_algorithms + + for algo in algorithms: + f = getattr(hashlib, algo) + try: + h = f(source) + except ValueError: + # This can happen for hash algorithms not supported in FIPS mode + # (https://github.com/ansible/ansible/issues/67213) + continue + try: + # Certain hash functions have a hexdigest() which expects a length parameter + pubkey_digest = h.hexdigest() + except TypeError: + pubkey_digest = h.hexdigest(32) + fingerprint[algo] = ':'.join(pubkey_digest[i:i + 2] for i in range(0, len(pubkey_digest), 2)) + if prefer_one: + break + + return fingerprint + + +def get_fingerprint_of_privatekey(privatekey, backend='cryptography', prefer_one=False): + """Generate the fingerprint of the public key. """ + + if backend == 'cryptography': + publickey = privatekey.public_key().public_bytes( + serialization.Encoding.DER, + serialization.PublicFormat.SubjectPublicKeyInfo + ) + + return get_fingerprint_of_bytes(publickey, prefer_one=prefer_one) + + +def get_fingerprint(path, passphrase=None, content=None, backend='cryptography', prefer_one=False): + """Generate the fingerprint of the public key. """ + + privatekey = load_privatekey(path, passphrase=passphrase, content=content, check_passphrase=False, backend=backend) + + return get_fingerprint_of_privatekey(privatekey, backend=backend, prefer_one=prefer_one) + + +def load_privatekey(path, passphrase=None, check_passphrase=True, content=None, backend='cryptography'): + """Load the specified OpenSSL private key. + + The content can also be specified via content; in that case, + this function will not load the key from disk. + """ + + try: + if content is None: + with open(path, 'rb') as b_priv_key_fh: + priv_key_detail = b_priv_key_fh.read() + else: + priv_key_detail = content + except (IOError, OSError) as exc: + raise OpenSSLObjectError(exc) + + if backend == 'pyopenssl': + + # First try: try to load with real passphrase (resp. empty string) + # Will work if this is the correct passphrase, or the key is not + # password-protected. + try: + result = crypto.load_privatekey(crypto.FILETYPE_PEM, + priv_key_detail, + to_bytes(passphrase or '')) + except crypto.Error as e: + if len(e.args) > 0 and len(e.args[0]) > 0: + if e.args[0][0][2] in ('bad decrypt', 'bad password read'): + # This happens in case we have the wrong passphrase. + if passphrase is not None: + raise OpenSSLBadPassphraseError('Wrong passphrase provided for private key!') + else: + raise OpenSSLBadPassphraseError('No passphrase provided, but private key is password-protected!') + raise OpenSSLObjectError('Error while deserializing key: {0}'.format(e)) + if check_passphrase: + # Next we want to make sure that the key is actually protected by + # a passphrase (in case we did try the empty string before, make + # sure that the key is not protected by the empty string) + try: + crypto.load_privatekey(crypto.FILETYPE_PEM, + priv_key_detail, + to_bytes('y' if passphrase == 'x' else 'x')) + if passphrase is not None: + # Since we can load the key without an exception, the + # key is not password-protected + raise OpenSSLBadPassphraseError('Passphrase provided, but private key is not password-protected!') + except crypto.Error as e: + if passphrase is None and len(e.args) > 0 and len(e.args[0]) > 0: + if e.args[0][0][2] in ('bad decrypt', 'bad password read'): + # The key is obviously protected by the empty string. + # Do not do this at home (if it's possible at all)... + raise OpenSSLBadPassphraseError('No passphrase provided, but private key is password-protected!') + elif backend == 'cryptography': + try: + result = load_pem_private_key(priv_key_detail, + None if passphrase is None else to_bytes(passphrase), + cryptography_backend()) + except TypeError: + raise OpenSSLBadPassphraseError('Wrong or empty passphrase provided for private key') + except ValueError: + raise OpenSSLBadPassphraseError('Wrong passphrase provided for private key') + + return result + + +def load_publickey(path=None, content=None, backend=None): + if content is None: + if path is None: + raise OpenSSLObjectError('Must provide either path or content') + try: + with open(path, 'rb') as b_priv_key_fh: + content = b_priv_key_fh.read() + except (IOError, OSError) as exc: + raise OpenSSLObjectError(exc) + + if backend == 'cryptography': + try: + return serialization.load_pem_public_key(content, backend=cryptography_backend()) + except Exception as e: + raise OpenSSLObjectError('Error while deserializing key: {0}'.format(e)) + + +def load_certificate(path, content=None, backend='cryptography'): + """Load the specified certificate.""" + + try: + if content is None: + with open(path, 'rb') as cert_fh: + cert_content = cert_fh.read() + else: + cert_content = content + except (IOError, OSError) as exc: + raise OpenSSLObjectError(exc) + if backend == 'pyopenssl': + return crypto.load_certificate(crypto.FILETYPE_PEM, cert_content) + elif backend == 'cryptography': + try: + return x509.load_pem_x509_certificate(cert_content, cryptography_backend()) + except ValueError as exc: + raise OpenSSLObjectError(exc) + + +def load_certificate_request(path, content=None, backend='cryptography'): + """Load the specified certificate signing request.""" + try: + if content is None: + with open(path, 'rb') as csr_fh: + csr_content = csr_fh.read() + else: + csr_content = content + except (IOError, OSError) as exc: + raise OpenSSLObjectError(exc) + if backend == 'cryptography': + try: + return x509.load_pem_x509_csr(csr_content, cryptography_backend()) + except ValueError as exc: + raise OpenSSLObjectError(exc) + + +def parse_name_field(input_dict, name_field_name=None): + """Take a dict with key: value or key: list_of_values mappings and return a list of tuples""" + error_str = '{key}' if name_field_name is None else '{key} in {name}' + + result = [] + for key, value in input_dict.items(): + if isinstance(value, list): + for entry in value: + if not isinstance(entry, six.string_types): + raise TypeError(('Values %s must be strings' % error_str).format(key=key, name=name_field_name)) + if not entry: + raise ValueError(('Values for %s must not be empty strings' % error_str).format(key=key)) + result.append((key, entry)) + elif isinstance(value, six.string_types): + if not value: + raise ValueError(('Value for %s must not be an empty string' % error_str).format(key=key)) + result.append((key, value)) + else: + raise TypeError(('Value for %s must be either a string or a list of strings' % error_str).format(key=key)) + return result + + +def parse_ordered_name_field(input_list, name_field_name): + """Take a dict with key: value or key: list_of_values mappings and return a list of tuples""" + + result = [] + for index, entry in enumerate(input_list): + if len(entry) != 1: + raise ValueError( + 'Entry #{index} in {name} must be a dictionary with exactly one key-value pair'.format( + name=name_field_name, index=index + 1)) + try: + result.extend(parse_name_field(entry, name_field_name=name_field_name)) + except (TypeError, ValueError) as exc: + raise ValueError( + 'Error while processing entry #{index} in {name}: {error}'.format( + name=name_field_name, index=index + 1, error=exc)) + return result + + +def convert_relative_to_datetime(relative_time_string): + """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 parsed_result.group("prefix") == "+": + return datetime.datetime.utcnow() + offset + else: + return datetime.datetime.utcnow() - offset + + +def get_relative_time_option(input_string, input_name, backend='cryptography'): + """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) + 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: + return datetime.datetime.strptime(result, date_fmt) + except ValueError: + pass + + 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': + digest = hashes.SHA256() + elif digest_string == 'sha384': + digest = hashes.SHA384() + elif digest_string == 'sha512': + digest = hashes.SHA512() + elif digest_string == 'sha1': + digest = hashes.SHA1() + elif digest_string == 'md5': + digest = hashes.MD5() + return digest + + +@six.add_metaclass(abc.ABCMeta) +class OpenSSLObject(object): + + def __init__(self, path, state, force, check_mode): + self.path = path + self.state = state + self.force = force + self.name = os.path.basename(path) + self.changed = False + self.check_mode = check_mode + + def check(self, module, perms_required=True): + """Ensure the resource is in its desired state.""" + + def _check_state(): + return os.path.exists(self.path) + + def _check_perms(module): + file_args = module.load_file_common_arguments(module.params) + if module.check_file_absent_if_check_mode(file_args['path']): + return False + return not module.set_fs_attributes_if_different(file_args, False) + + if not perms_required: + return _check_state() + + return _check_state() and _check_perms(module) + + @abc.abstractmethod + def dump(self): + """Serialize the object into a dictionary.""" + + pass + + @abc.abstractmethod + def generate(self): + """Generate the resource.""" + + pass + + def remove(self, module): + """Remove the resource from the filesystem.""" + if self.check_mode: + if os.path.exists(self.path): + self.changed = True + return + + try: + os.remove(self.path) + self.changed = True + except OSError as exc: + if exc.errno != errno.ENOENT: + raise OpenSSLObjectError(exc) + else: + pass diff --git a/ansible_collections/community/crypto/plugins/module_utils/ecs/api.py b/ansible_collections/community/crypto/plugins/module_utils/ecs/api.py new file mode 100644 index 00000000..bf8be58f --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/ecs/api.py @@ -0,0 +1,346 @@ +# -*- coding: utf-8 -*- + +# This code is part of Ansible, but is an independent component. +# This particular file snippet, and this file snippet only, is licensed under the +# Modified BSD License. Modules you write using this snippet, which is embedded +# dynamically by Ansible, still belong to the author of the module, and may assign +# their own license to the complete work. +# +# Copyright (c), Entrust Datacard Corporation, 2019 +# Simplified BSD License (see LICENSES/BSD-2-Clause.txt or https://opensource.org/licenses/BSD-2-Clause) +# SPDX-License-Identifier: BSD-2-Clause + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import json +import os +import re +import traceback + +from ansible.module_utils.common.text.converters import to_text, to_native +from ansible.module_utils.basic import missing_required_lib +from ansible.module_utils.six.moves.urllib.parse import urlencode +from ansible.module_utils.six.moves.urllib.error import HTTPError +from ansible.module_utils.urls import Request + +YAML_IMP_ERR = None +try: + import yaml +except ImportError: + YAML_FOUND = False + YAML_IMP_ERR = traceback.format_exc() +else: + YAML_FOUND = True + +valid_file_format = re.compile(r".*(\.)(yml|yaml|json)$") + + +def ecs_client_argument_spec(): + return dict( + entrust_api_user=dict(type='str', required=True), + entrust_api_key=dict(type='str', required=True, no_log=True), + entrust_api_client_cert_path=dict(type='path', required=True), + entrust_api_client_cert_key_path=dict(type='path', required=True, no_log=True), + entrust_api_specification_path=dict(type='path', default='https://cloud.entrust.net/EntrustCloud/documentation/cms-api-2.1.0.yaml'), + ) + + +class SessionConfigurationException(Exception): + """ Raised if we cannot configure a session with the API """ + + pass + + +class RestOperationException(Exception): + """ Encapsulate a REST API error """ + + def __init__(self, error): + self.status = to_native(error.get("status", None)) + self.errors = [to_native(err.get("message")) for err in error.get("errors", {})] + self.message = to_native(" ".join(self.errors)) + + +def generate_docstring(operation_spec): + """Generate a docstring for an operation defined in operation_spec (swagger)""" + # Description of the operation + docs = operation_spec.get("description", "No Description") + docs += "\n\n" + + # Parameters of the operation + parameters = operation_spec.get("parameters", []) + if len(parameters) != 0: + docs += "\tArguments:\n\n" + for parameter in parameters: + docs += "{0} ({1}:{2}): {3}\n".format( + parameter.get("name"), + parameter.get("type", "No Type"), + "Required" if parameter.get("required", False) else "Not Required", + parameter.get("description"), + ) + + return docs + + +def bind(instance, method, operation_spec): + def binding_scope_fn(*args, **kwargs): + return method(instance, *args, **kwargs) + + # Make sure we do not confuse users; add the proper name and documentation to the function. + # Users can use !help(<function>) to get help on the function from interactive python or pdb + operation_name = operation_spec.get("operationId").split("Using")[0] + binding_scope_fn.__name__ = str(operation_name) + binding_scope_fn.__doc__ = generate_docstring(operation_spec) + + return binding_scope_fn + + +class RestOperation(object): + def __init__(self, session, uri, method, parameters=None): + self.session = session + self.method = method + if parameters is None: + self.parameters = {} + else: + self.parameters = parameters + self.url = "{scheme}://{host}{base_path}{uri}".format(scheme="https", host=session._spec.get("host"), base_path=session._spec.get("basePath"), uri=uri) + + def restmethod(self, *args, **kwargs): + """Do the hard work of making the request here""" + + # gather named path parameters and do substitution on the URL + if self.parameters: + path_parameters = {} + body_parameters = {} + query_parameters = {} + for x in self.parameters: + expected_location = x.get("in") + key_name = x.get("name", None) + key_value = kwargs.get(key_name, None) + if expected_location == "path" and key_name and key_value: + path_parameters.update({key_name: key_value}) + elif expected_location == "body" and key_name and key_value: + body_parameters.update({key_name: key_value}) + elif expected_location == "query" and key_name and key_value: + query_parameters.update({key_name: key_value}) + + if len(body_parameters.keys()) >= 1: + body_parameters = body_parameters.get(list(body_parameters.keys())[0]) + else: + body_parameters = None + else: + path_parameters = {} + query_parameters = {} + body_parameters = None + + # This will fail if we have not set path parameters with a KeyError + url = self.url.format(**path_parameters) + if query_parameters: + # modify the URL to add path parameters + url = url + "?" + urlencode(query_parameters) + + try: + if body_parameters: + body_parameters_json = json.dumps(body_parameters) + response = self.session.request.open(method=self.method, url=url, data=body_parameters_json) + else: + response = self.session.request.open(method=self.method, url=url) + request_error = False + except HTTPError as e: + # An HTTPError has the same methods available as a valid response from request.open + response = e + request_error = True + + # Return the result if JSON and success ({} for empty responses) + # Raise an exception if there was a failure. + try: + result_code = response.getcode() + result = json.loads(response.read()) + except ValueError: + result = {} + + if result or result == {}: + if result_code and result_code < 400: + return result + else: + raise RestOperationException(result) + + # Raise a generic RestOperationException if this fails + raise RestOperationException({"status": result_code, "errors": [{"message": "REST Operation Failed"}]}) + + +class Resource(object): + """ Implement basic CRUD operations against a path. """ + + def __init__(self, session): + self.session = session + self.parameters = {} + + for url in session._spec.get("paths").keys(): + methods = session._spec.get("paths").get(url) + for method in methods.keys(): + operation_spec = methods.get(method) + operation_name = operation_spec.get("operationId", None) + parameters = operation_spec.get("parameters") + + if not operation_name: + if method.lower() == "post": + operation_name = "Create" + elif method.lower() == "get": + operation_name = "Get" + elif method.lower() == "put": + operation_name = "Update" + elif method.lower() == "delete": + operation_name = "Delete" + elif method.lower() == "patch": + operation_name = "Patch" + else: + raise SessionConfigurationException(to_native("Invalid REST method type {0}".format(method))) + + # Get the non-parameter parts of the URL and append to the operation name + # e.g /application/version -> GetApplicationVersion + # e.g. /application/{id} -> GetApplication + # This may lead to duplicates, which we must prevent. + operation_name += re.sub(r"{(.*)}", "", url).replace("/", " ").title().replace(" ", "") + operation_spec["operationId"] = operation_name + + op = RestOperation(session, url, method, parameters) + setattr(self, operation_name, bind(self, op.restmethod, operation_spec)) + + +# Session to encapsulate the connection parameters of the module_utils Request object, the api spec, etc +class ECSSession(object): + def __init__(self, name, **kwargs): + """ + Initialize our session + """ + + self._set_config(name, **kwargs) + + def client(self): + resource = Resource(self) + return resource + + def _set_config(self, name, **kwargs): + headers = { + "Content-Type": "application/json", + "Connection": "keep-alive", + } + self.request = Request(headers=headers, timeout=60) + + configurators = [self._read_config_vars] + for configurator in configurators: + self._config = configurator(name, **kwargs) + if self._config: + break + if self._config is None: + raise SessionConfigurationException(to_native("No Configuration Found.")) + + # set up auth if passed + entrust_api_user = self.get_config("entrust_api_user") + entrust_api_key = self.get_config("entrust_api_key") + if entrust_api_user and entrust_api_key: + self.request.url_username = entrust_api_user + self.request.url_password = entrust_api_key + else: + raise SessionConfigurationException(to_native("User and key must be provided.")) + + # set up client certificate if passed (support all-in one or cert + key) + entrust_api_cert = self.get_config("entrust_api_cert") + entrust_api_cert_key = self.get_config("entrust_api_cert_key") + if entrust_api_cert: + self.request.client_cert = entrust_api_cert + if entrust_api_cert_key: + self.request.client_key = entrust_api_cert_key + else: + raise SessionConfigurationException(to_native("Client certificate for authentication to the API must be provided.")) + + # set up the spec + entrust_api_specification_path = self.get_config("entrust_api_specification_path") + + if not entrust_api_specification_path.startswith("http") and not os.path.isfile(entrust_api_specification_path): + raise SessionConfigurationException(to_native("OpenAPI specification was not found at location {0}.".format(entrust_api_specification_path))) + if not valid_file_format.match(entrust_api_specification_path): + raise SessionConfigurationException(to_native("OpenAPI specification filename must end in .json, .yml or .yaml")) + + self.verify = True + + if entrust_api_specification_path.startswith("http"): + try: + http_response = Request().open(method="GET", url=entrust_api_specification_path) + http_response_contents = http_response.read() + if entrust_api_specification_path.endswith(".json"): + self._spec = json.load(http_response_contents) + elif entrust_api_specification_path.endswith(".yml") or entrust_api_specification_path.endswith(".yaml"): + self._spec = yaml.safe_load(http_response_contents) + except HTTPError as e: + raise SessionConfigurationException(to_native("Error downloading specification from address '{0}', received error code '{1}'".format( + entrust_api_specification_path, e.getcode()))) + else: + with open(entrust_api_specification_path) as f: + if ".json" in entrust_api_specification_path: + self._spec = json.load(f) + elif ".yml" in entrust_api_specification_path or ".yaml" in entrust_api_specification_path: + self._spec = yaml.safe_load(f) + + def get_config(self, item): + return self._config.get(item, None) + + def _read_config_vars(self, name, **kwargs): + """ Read configuration from variables passed to the module. """ + config = {} + + entrust_api_specification_path = kwargs.get("entrust_api_specification_path") + if not entrust_api_specification_path or (not entrust_api_specification_path.startswith("http") and not os.path.isfile(entrust_api_specification_path)): + raise SessionConfigurationException( + to_native( + "Parameter provided for entrust_api_specification_path of value '{0}' was not a valid file path or HTTPS address.".format( + entrust_api_specification_path + ) + ) + ) + + for required_file in ["entrust_api_cert", "entrust_api_cert_key"]: + file_path = kwargs.get(required_file) + if not file_path or not os.path.isfile(file_path): + raise SessionConfigurationException( + to_native("Parameter provided for {0} of value '{1}' was not a valid file path.".format(required_file, file_path)) + ) + + for required_var in ["entrust_api_user", "entrust_api_key"]: + if not kwargs.get(required_var): + raise SessionConfigurationException(to_native("Parameter provided for {0} was missing.".format(required_var))) + + config["entrust_api_cert"] = kwargs.get("entrust_api_cert") + config["entrust_api_cert_key"] = kwargs.get("entrust_api_cert_key") + config["entrust_api_specification_path"] = kwargs.get("entrust_api_specification_path") + config["entrust_api_user"] = kwargs.get("entrust_api_user") + config["entrust_api_key"] = kwargs.get("entrust_api_key") + + return config + + +def ECSClient(entrust_api_user=None, entrust_api_key=None, entrust_api_cert=None, entrust_api_cert_key=None, entrust_api_specification_path=None): + """Create an ECS client""" + + if not YAML_FOUND: + raise SessionConfigurationException(missing_required_lib("PyYAML"), exception=YAML_IMP_ERR) + + if entrust_api_specification_path is None: + entrust_api_specification_path = "https://cloud.entrust.net/EntrustCloud/documentation/cms-api-2.1.0.yaml" + + # Not functionally necessary with current uses of this module_util, but better to be explicit for future use cases + entrust_api_user = to_text(entrust_api_user) + entrust_api_key = to_text(entrust_api_key) + entrust_api_cert_key = to_text(entrust_api_cert_key) + entrust_api_specification_path = to_text(entrust_api_specification_path) + + return ECSSession( + "ecs", + entrust_api_user=entrust_api_user, + entrust_api_key=entrust_api_key, + entrust_api_cert=entrust_api_cert, + entrust_api_cert_key=entrust_api_cert_key, + entrust_api_specification_path=entrust_api_specification_path, + ).client() diff --git a/ansible_collections/community/crypto/plugins/module_utils/io.py b/ansible_collections/community/crypto/plugins/module_utils/io.py new file mode 100644 index 00000000..6c2f33be --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/io.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2016, Yanis Guenane <yanis+ansible@guenane.org> +# 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 errno +import os +import tempfile + + +def load_file(path, module=None): + ''' + Load the file as a bytes string. + ''' + try: + with open(path, 'rb') as f: + return f.read() + except Exception as exc: + if module is None: + raise + module.fail_json('Error while loading {0} - {1}'.format(path, str(exc))) + + +def load_file_if_exists(path, module=None, ignore_errors=False): + ''' + Load the file as a bytes string. If the file does not exist, ``None`` is returned. + + If ``ignore_errors`` is ``True``, will ignore errors. Otherwise, errors are + raised as exceptions if ``module`` is not specified, and result in ``module.fail_json`` + being called when ``module`` is specified. + ''' + try: + with open(path, 'rb') as f: + return f.read() + except EnvironmentError as exc: + if exc.errno == errno.ENOENT: + return None + if ignore_errors: + return None + if module is None: + raise + module.fail_json('Error while loading {0} - {1}'.format(path, str(exc))) + except Exception as exc: + if ignore_errors: + return None + if module is None: + raise + module.fail_json('Error while loading {0} - {1}'.format(path, str(exc))) + + +def write_file(module, content, default_mode=None, path=None): + ''' + Writes content into destination file as securely as possible. + Uses file arguments from module. + ''' + # Find out parameters for file + try: + file_args = module.load_file_common_arguments(module.params, path=path) + except TypeError: + # The path argument is only supported in Ansible 2.10+. Fall back to + # pre-2.10 behavior of module_utils/crypto.py for older Ansible versions. + file_args = module.load_file_common_arguments(module.params) + if path is not None: + file_args['path'] = path + if file_args['mode'] is None: + file_args['mode'] = default_mode + # Create tempfile name + tmp_fd, tmp_name = tempfile.mkstemp(prefix=b'.ansible_tmp') + try: + os.close(tmp_fd) + except Exception: + pass + module.add_cleanup_file(tmp_name) # if we fail, let Ansible try to remove the file + try: + try: + # Create tempfile + file = os.open(tmp_name, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + os.write(file, content) + os.close(file) + except Exception as e: + try: + os.remove(tmp_name) + except Exception: + pass + module.fail_json(msg='Error while writing result into temporary file: {0}'.format(e)) + # Update destination to wanted permissions + if os.path.exists(file_args['path']): + module.set_fs_attributes_if_different(file_args, False) + # Move tempfile to final destination + module.atomic_move(tmp_name, file_args['path']) + # Try to update permissions again + if not module.check_file_absent_if_check_mode(file_args['path']): + module.set_fs_attributes_if_different(file_args, False) + except Exception as e: + try: + os.remove(tmp_name) + except Exception: + pass + module.fail_json(msg='Error while writing result: {0}'.format(e)) diff --git a/ansible_collections/community/crypto/plugins/module_utils/openssh/backends/common.py b/ansible_collections/community/crypto/plugins/module_utils/openssh/backends/common.py new file mode 100644 index 00000000..6e274a6d --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/openssh/backends/common.py @@ -0,0 +1,346 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2021, Andrew Pantuso (@ajpantuso) <ajpantuso@gmail.com> +# 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 abc +import os +import stat +import traceback + +from ansible.module_utils import six + +from ansible.module_utils.common.text.converters import to_native +from ansible_collections.community.crypto.plugins.module_utils.openssh.utils import ( + parse_openssh_version, +) + + +def restore_on_failure(f): + def backup_and_restore(module, path, *args, **kwargs): + backup_file = module.backup_local(path) if os.path.exists(path) else None + + try: + f(module, path, *args, **kwargs) + except Exception: + if backup_file is not None: + module.atomic_move(backup_file, path) + raise + else: + module.add_cleanup_file(backup_file) + + return backup_and_restore + + +@restore_on_failure +def safe_atomic_move(module, path, destination): + module.atomic_move(path, destination) + + +def _restore_all_on_failure(f): + def backup_and_restore(self, sources_and_destinations, *args, **kwargs): + backups = [(d, self.module.backup_local(d)) for s, d in sources_and_destinations if os.path.exists(d)] + + try: + f(self, sources_and_destinations, *args, **kwargs) + except Exception: + for destination, backup in backups: + self.module.atomic_move(backup, destination) + raise + else: + for destination, backup in backups: + self.module.add_cleanup_file(backup) + return backup_and_restore + + +@six.add_metaclass(abc.ABCMeta) +class OpensshModule(object): + def __init__(self, module): + self.module = module + + self.changed = False + self.check_mode = self.module.check_mode + + def execute(self): + try: + self._execute() + except Exception as e: + self.module.fail_json( + msg="unexpected error occurred: %s" % to_native(e), + exception=traceback.format_exc(), + ) + + self.module.exit_json(**self.result) + + @abc.abstractmethod + def _execute(self): + pass + + @property + def result(self): + result = self._result + + result['changed'] = self.changed + + if self.module._diff: + result['diff'] = self.diff + + return result + + @property + @abc.abstractmethod + def _result(self): + pass + + @property + @abc.abstractmethod + def diff(self): + pass + + @staticmethod + def skip_if_check_mode(f): + def wrapper(self, *args, **kwargs): + if not self.check_mode: + f(self, *args, **kwargs) + return wrapper + + @staticmethod + def trigger_change(f): + def wrapper(self, *args, **kwargs): + f(self, *args, **kwargs) + self.changed = True + return wrapper + + def _check_if_base_dir(self, path): + base_dir = os.path.dirname(path) or '.' + if not os.path.isdir(base_dir): + self.module.fail_json( + name=base_dir, + msg='The directory %s does not exist or the file is not a directory' % base_dir + ) + + def _get_ssh_version(self): + ssh_bin = self.module.get_bin_path('ssh') + if not ssh_bin: + return "" + return parse_openssh_version(self.module.run_command([ssh_bin, '-V', '-q'])[2].strip()) + + @_restore_all_on_failure + def _safe_secure_move(self, sources_and_destinations): + """Moves a list of files from 'source' to 'destination' and restores 'destination' from backup upon failure. + If 'destination' does not already exist, then 'source' permissions are preserved to prevent + exposing protected data ('atomic_move' uses the 'destination' base directory mask for + permissions if 'destination' does not already exists). + """ + for source, destination in sources_and_destinations: + if os.path.exists(destination): + self.module.atomic_move(source, destination) + else: + self.module.preserved_copy(source, destination) + + def _update_permissions(self, path): + file_args = self.module.load_file_common_arguments(self.module.params) + file_args['path'] = path + + if not self.module.check_file_absent_if_check_mode(path): + self.changed = self.module.set_fs_attributes_if_different(file_args, self.changed) + else: + self.changed = True + + +class KeygenCommand(object): + def __init__(self, module): + self._bin_path = module.get_bin_path('ssh-keygen', True) + self._run_command = module.run_command + + def generate_certificate(self, certificate_path, identifier, options, pkcs11_provider, principals, + serial_number, signature_algorithm, signing_key_path, type, + time_parameters, use_agent, **kwargs): + args = [self._bin_path, '-s', signing_key_path, '-P', '', '-I', identifier] + + if options: + for option in options: + args.extend(['-O', option]) + if pkcs11_provider: + args.extend(['-D', pkcs11_provider]) + if principals: + args.extend(['-n', ','.join(principals)]) + if serial_number is not None: + args.extend(['-z', str(serial_number)]) + if type == 'host': + args.extend(['-h']) + if use_agent: + args.extend(['-U']) + if time_parameters.validity_string: + args.extend(['-V', time_parameters.validity_string]) + if signature_algorithm: + args.extend(['-t', signature_algorithm]) + args.append(certificate_path) + + return self._run_command(args, **kwargs) + + def generate_keypair(self, private_key_path, size, type, comment, **kwargs): + args = [ + self._bin_path, + '-q', + '-N', '', + '-b', str(size), + '-t', type, + '-f', private_key_path, + '-C', comment or '' + ] + + # "y" must be entered in response to the "overwrite" prompt + data = 'y' if os.path.exists(private_key_path) else None + + return self._run_command(args, data=data, **kwargs) + + def get_certificate_info(self, certificate_path, **kwargs): + return self._run_command([self._bin_path, '-L', '-f', certificate_path], **kwargs) + + def get_matching_public_key(self, private_key_path, **kwargs): + return self._run_command([self._bin_path, '-P', '', '-y', '-f', private_key_path], **kwargs) + + def get_private_key(self, private_key_path, **kwargs): + return self._run_command([self._bin_path, '-l', '-f', private_key_path], **kwargs) + + def update_comment(self, private_key_path, comment, **kwargs): + if os.path.exists(private_key_path) and not os.access(private_key_path, os.W_OK): + try: + os.chmod(private_key_path, stat.S_IWUSR + stat.S_IRUSR) + except (IOError, OSError) as e: + raise e("The private key at %s is not writeable preventing a comment update" % private_key_path) + + return self._run_command([self._bin_path, '-q', '-o', '-c', '-C', comment, '-f', private_key_path], **kwargs) + + +class PrivateKey(object): + def __init__(self, size, key_type, fingerprint, format=''): + self._size = size + self._type = key_type + self._fingerprint = fingerprint + self._format = format + + @property + def size(self): + return self._size + + @property + def type(self): + return self._type + + @property + def fingerprint(self): + return self._fingerprint + + @property + def format(self): + return self._format + + @classmethod + def from_string(cls, string): + properties = string.split() + + return cls( + size=int(properties[0]), + key_type=properties[-1][1:-1].lower(), + fingerprint=properties[1], + ) + + def to_dict(self): + return { + 'size': self._size, + 'type': self._type, + 'fingerprint': self._fingerprint, + 'format': self._format, + } + + +class PublicKey(object): + def __init__(self, type_string, data, comment): + self._type_string = type_string + self._data = data + self._comment = comment + + def __eq__(self, other): + if not isinstance(other, type(self)): + return NotImplemented + + return all([ + self._type_string == other._type_string, + self._data == other._data, + (self._comment == other._comment) if self._comment is not None and other._comment is not None else True + ]) + + def __ne__(self, other): + return not self == other + + def __str__(self): + return "%s %s" % (self._type_string, self._data) + + @property + def comment(self): + return self._comment + + @comment.setter + def comment(self, value): + self._comment = value + + @property + def data(self): + return self._data + + @property + def type_string(self): + return self._type_string + + @classmethod + def from_string(cls, string): + properties = string.strip('\n').split(' ', 2) + + return cls( + type_string=properties[0], + data=properties[1], + comment=properties[2] if len(properties) > 2 else "" + ) + + @classmethod + def load(cls, path): + try: + with open(path, 'r') as f: + properties = f.read().strip(' \n').split(' ', 2) + except (IOError, OSError): + raise + + if len(properties) < 2: + return None + + return cls( + type_string=properties[0], + data=properties[1], + comment='' if len(properties) <= 2 else properties[2], + ) + + def to_dict(self): + return { + 'comment': self._comment, + 'public_key': self._data, + } + + +def parse_private_key_format(path): + with open(path, 'r') as file: + header = file.readline().strip() + + if header == '-----BEGIN OPENSSH PRIVATE KEY-----': + return 'SSH' + elif header == '-----BEGIN PRIVATE KEY-----': + return 'PKCS8' + elif header == '-----BEGIN RSA PRIVATE KEY-----': + return 'PKCS1' + + return '' diff --git a/ansible_collections/community/crypto/plugins/module_utils/openssh/backends/keypair_backend.py b/ansible_collections/community/crypto/plugins/module_utils/openssh/backends/keypair_backend.py new file mode 100644 index 00000000..8cc39c6f --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/openssh/backends/keypair_backend.py @@ -0,0 +1,480 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2018, David Kainz <dkainz@mgit.at> <dave.jokain@gmx.at> +# Copyright (c) 2021, Andrew Pantuso (@ajpantuso) <ajpantuso@gmail.com> +# 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 abc +import os + +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, to_bytes + +from ansible_collections.community.crypto.plugins.module_utils.version import LooseVersion + +from ansible_collections.community.crypto.plugins.module_utils.openssh.cryptography import ( + HAS_OPENSSH_SUPPORT, + HAS_OPENSSH_PRIVATE_FORMAT, + InvalidCommentError, + InvalidPassphraseError, + InvalidPrivateKeyFileError, + OpenSSHError, + OpensshKeypair, +) +from ansible_collections.community.crypto.plugins.module_utils.openssh.backends.common import ( + KeygenCommand, + OpensshModule, + PrivateKey, + PublicKey, + parse_private_key_format, +) +from ansible_collections.community.crypto.plugins.module_utils.openssh.utils import ( + any_in, + file_mode, + secure_write, +) + + +@six.add_metaclass(abc.ABCMeta) +class KeypairBackend(OpensshModule): + + def __init__(self, module): + super(KeypairBackend, self).__init__(module) + + self.comment = self.module.params['comment'] + self.private_key_path = self.module.params['path'] + self.public_key_path = self.private_key_path + '.pub' + self.regenerate = self.module.params['regenerate'] if not self.module.params['force'] else 'always' + self.state = self.module.params['state'] + self.type = self.module.params['type'] + + self.size = self._get_size(self.module.params['size']) + self._validate_path() + + self.original_private_key = None + self.original_public_key = None + self.private_key = None + self.public_key = None + + def _get_size(self, size): + if self.type in ('rsa', 'rsa1'): + result = 4096 if size is None else size + if result < 1024: + return self.module.fail_json( + msg="For RSA keys, the minimum size is 1024 bits and the default is 4096 bits. " + + "Attempting to use bit lengths under 1024 will cause the module to fail." + ) + elif self.type == 'dsa': + result = 1024 if size is None else size + if result != 1024: + return self.module.fail_json(msg="DSA keys must be exactly 1024 bits as specified by FIPS 186-2.") + elif self.type == 'ecdsa': + result = 256 if size is None else size + if result not in (256, 384, 521): + return self.module.fail_json( + msg="For ECDSA keys, size determines the key length by selecting from one of " + + "three elliptic curve sizes: 256, 384 or 521 bits. " + + "Attempting to use bit lengths other than these three values for ECDSA keys will " + + "cause this module to fail." + ) + elif self.type == 'ed25519': + # User input is ignored for `key size` when `key type` is ed25519 + result = 256 + else: + return self.module.fail_json(msg="%s is not a valid value for key type" % self.type) + + return result + + def _validate_path(self): + self._check_if_base_dir(self.private_key_path) + + if os.path.isdir(self.private_key_path): + self.module.fail_json(msg='%s is a directory. Please specify a path to a file.' % self.private_key_path) + + def _execute(self): + self.original_private_key = self._load_private_key() + self.original_public_key = self._load_public_key() + + if self.state == 'present': + self._validate_key_load() + + if self._should_generate(): + self._generate() + elif not self._public_key_valid(): + self._restore_public_key() + + self.private_key = self._load_private_key() + self.public_key = self._load_public_key() + + for path in (self.private_key_path, self.public_key_path): + self._update_permissions(path) + else: + if self._should_remove(): + self._remove() + + def _load_private_key(self): + result = None + if self._private_key_exists(): + try: + result = self._get_private_key() + except Exception: + pass + + return result + + def _private_key_exists(self): + return os.path.exists(self.private_key_path) + + @abc.abstractmethod + def _get_private_key(self): + pass + + def _load_public_key(self): + result = None + if self._public_key_exists(): + try: + result = PublicKey.load(self.public_key_path) + except (IOError, OSError): + pass + return result + + def _public_key_exists(self): + return os.path.exists(self.public_key_path) + + def _validate_key_load(self): + if (self._private_key_exists() + and self.regenerate in ('never', 'fail', 'partial_idempotence') + and (self.original_private_key is None or not self._private_key_readable())): + self.module.fail_json( + msg="Unable to read the key. The key is protected with a passphrase or broken. " + + "Will not proceed. To force regeneration, call the module with `generate` " + + "set to `full_idempotence` or `always`, or with `force=true`." + ) + + @abc.abstractmethod + def _private_key_readable(self): + pass + + def _should_generate(self): + if self.regenerate == 'never': + return self.original_private_key is None + elif self.regenerate == 'fail': + if not self._private_key_valid(): + self.module.fail_json( + msg="Key has wrong type and/or size. Will not proceed. " + + "To force regeneration, call the module with `generate` set to " + + "`partial_idempotence`, `full_idempotence` or `always`, or with `force=true`." + ) + return self.original_private_key is None + elif self.regenerate in ('partial_idempotence', 'full_idempotence'): + return not self._private_key_valid() + else: + return True + + def _private_key_valid(self): + if self.original_private_key is None: + return False + + return all([ + self.size == self.original_private_key.size, + self.type == self.original_private_key.type, + self._private_key_valid_backend(), + ]) + + @abc.abstractmethod + def _private_key_valid_backend(self): + pass + + @OpensshModule.trigger_change + @OpensshModule.skip_if_check_mode + def _generate(self): + temp_private_key, temp_public_key = self._generate_temp_keypair() + + try: + self._safe_secure_move([(temp_private_key, self.private_key_path), (temp_public_key, self.public_key_path)]) + except OSError as e: + self.module.fail_json(msg=to_native(e)) + + def _generate_temp_keypair(self): + temp_private_key = os.path.join(self.module.tmpdir, os.path.basename(self.private_key_path)) + temp_public_key = temp_private_key + '.pub' + + try: + self._generate_keypair(temp_private_key) + except (IOError, OSError) as e: + self.module.fail_json(msg=to_native(e)) + + for f in (temp_private_key, temp_public_key): + self.module.add_cleanup_file(f) + + return temp_private_key, temp_public_key + + @abc.abstractmethod + def _generate_keypair(self, private_key_path): + pass + + def _public_key_valid(self): + if self.original_public_key is None: + return False + + valid_public_key = self._get_public_key() + valid_public_key.comment = self.comment + + return self.original_public_key == valid_public_key + + @abc.abstractmethod + def _get_public_key(self): + pass + + @OpensshModule.trigger_change + @OpensshModule.skip_if_check_mode + def _restore_public_key(self): + try: + temp_public_key = self._create_temp_public_key(str(self._get_public_key()) + '\n') + self._safe_secure_move([ + (temp_public_key, self.public_key_path) + ]) + except (IOError, OSError): + self.module.fail_json( + msg="The public key is missing or does not match the private key. " + + "Unable to regenerate the public key." + ) + + if self.comment: + self._update_comment() + + def _create_temp_public_key(self, content): + temp_public_key = os.path.join(self.module.tmpdir, os.path.basename(self.public_key_path)) + + default_permissions = 0o644 + existing_permissions = file_mode(self.public_key_path) + + try: + secure_write(temp_public_key, existing_permissions or default_permissions, to_bytes(content)) + except (IOError, OSError) as e: + self.module.fail_json(msg=to_native(e)) + self.module.add_cleanup_file(temp_public_key) + + return temp_public_key + + @abc.abstractmethod + def _update_comment(self): + pass + + def _should_remove(self): + return self._private_key_exists() or self._public_key_exists() + + @OpensshModule.trigger_change + @OpensshModule.skip_if_check_mode + def _remove(self): + try: + if self._private_key_exists(): + os.remove(self.private_key_path) + if self._public_key_exists(): + os.remove(self.public_key_path) + except (IOError, OSError) as e: + self.module.fail_json(msg=to_native(e)) + + @property + def _result(self): + private_key = self.private_key or self.original_private_key + public_key = self.public_key or self.original_public_key + + return { + 'size': self.size, + 'type': self.type, + 'filename': self.private_key_path, + 'fingerprint': private_key.fingerprint if private_key else '', + 'public_key': str(public_key) if public_key else '', + 'comment': public_key.comment if public_key else '', + } + + @property + def diff(self): + before = self.original_private_key.to_dict() if self.original_private_key else {} + before.update(self.original_public_key.to_dict() if self.original_public_key else {}) + + after = self.private_key.to_dict() if self.private_key else {} + after.update(self.public_key.to_dict() if self.public_key else {}) + + return { + 'before': before, + 'after': after, + } + + +class KeypairBackendOpensshBin(KeypairBackend): + def __init__(self, module): + super(KeypairBackendOpensshBin, self).__init__(module) + + if self.module.params['private_key_format'] != 'auto': + self.module.fail_json( + msg="'auto' is the only valid option for " + + "'private_key_format' when 'backend' is not 'cryptography'" + ) + + self.ssh_keygen = KeygenCommand(self.module) + + def _generate_keypair(self, private_key_path): + self.ssh_keygen.generate_keypair(private_key_path, self.size, self.type, self.comment) + + def _get_private_key(self): + private_key_content = self.ssh_keygen.get_private_key(self.private_key_path)[1] + return PrivateKey.from_string(private_key_content) + + def _get_public_key(self): + public_key_content = self.ssh_keygen.get_matching_public_key(self.private_key_path)[1] + return PublicKey.from_string(public_key_content) + + def _private_key_readable(self): + rc, stdout, stderr = self.ssh_keygen.get_matching_public_key(self.private_key_path) + return not (rc == 255 or any_in(stderr, 'is not a public key file', 'incorrect passphrase', 'load failed')) + + def _update_comment(self): + try: + self.ssh_keygen.update_comment(self.private_key_path, self.comment) + except (IOError, OSError) as e: + self.module.fail_json(msg=to_native(e)) + + def _private_key_valid_backend(self): + return True + + +class KeypairBackendCryptography(KeypairBackend): + def __init__(self, module): + super(KeypairBackendCryptography, self).__init__(module) + + if self.type == 'rsa1': + self.module.fail_json(msg="RSA1 keys are not supported by the cryptography backend") + + self.passphrase = to_bytes(module.params['passphrase']) if module.params['passphrase'] else None + self.private_key_format = self._get_key_format(module.params['private_key_format']) + + def _get_key_format(self, key_format): + result = 'SSH' + + if key_format == 'auto': + # Default to OpenSSH 7.8 compatibility when OpenSSH is not installed + ssh_version = self._get_ssh_version() or "7.8" + + if LooseVersion(ssh_version) < LooseVersion("7.8") and self.type != 'ed25519': + # OpenSSH made SSH formatted private keys available in version 6.5, + # but still defaulted to PKCS1 format with the exception of ed25519 keys + result = 'PKCS1' + + if result == 'SSH' and not HAS_OPENSSH_PRIVATE_FORMAT: + self.module.fail_json( + msg=missing_required_lib( + 'cryptography >= 3.0', + reason="to load/dump private keys in the default OpenSSH format for OpenSSH >= 7.8 " + + "or for ed25519 keys" + ) + ) + else: + result = key_format.upper() + + return result + + def _generate_keypair(self, private_key_path): + keypair = OpensshKeypair.generate( + keytype=self.type, + size=self.size, + passphrase=self.passphrase, + comment=self.comment or '', + ) + + encoded_private_key = OpensshKeypair.encode_openssh_privatekey( + keypair.asymmetric_keypair, self.private_key_format + ) + secure_write(private_key_path, 0o600, encoded_private_key) + + public_key_path = private_key_path + '.pub' + secure_write(public_key_path, 0o644, keypair.public_key) + + def _get_private_key(self): + keypair = OpensshKeypair.load(path=self.private_key_path, passphrase=self.passphrase, no_public_key=True) + + return PrivateKey( + size=keypair.size, + key_type=keypair.key_type, + fingerprint=keypair.fingerprint, + format=parse_private_key_format(self.private_key_path) + ) + + def _get_public_key(self): + try: + keypair = OpensshKeypair.load(path=self.private_key_path, passphrase=self.passphrase, no_public_key=True) + except OpenSSHError: + # Simulates the null output of ssh-keygen + return "" + + return PublicKey.from_string(to_text(keypair.public_key)) + + def _private_key_readable(self): + try: + OpensshKeypair.load(path=self.private_key_path, passphrase=self.passphrase, no_public_key=True) + except (InvalidPrivateKeyFileError, InvalidPassphraseError): + return False + + # Cryptography >= 3.0 uses a SSH key loader which does not raise an exception when a passphrase is provided + # when loading an unencrypted key + if self.passphrase: + try: + OpensshKeypair.load(path=self.private_key_path, passphrase=None, no_public_key=True) + except (InvalidPrivateKeyFileError, InvalidPassphraseError): + return True + else: + return False + + return True + + def _update_comment(self): + keypair = OpensshKeypair.load(path=self.private_key_path, passphrase=self.passphrase, no_public_key=True) + try: + keypair.comment = self.comment + except InvalidCommentError as e: + self.module.fail_json(msg=to_native(e)) + + try: + temp_public_key = self._create_temp_public_key(keypair.public_key + b'\n') + self._safe_secure_move([(temp_public_key, self.public_key_path)]) + except (IOError, OSError) as e: + self.module.fail_json(msg=to_native(e)) + + def _private_key_valid_backend(self): + # avoids breaking behavior and prevents + # automatic conversions with OpenSSH upgrades + if self.module.params['private_key_format'] == 'auto': + return True + + return self.private_key_format == self.original_private_key.format + + +def select_backend(module, backend): + can_use_cryptography = HAS_OPENSSH_SUPPORT + can_use_opensshbin = bool(module.get_bin_path('ssh-keygen')) + + if backend == 'auto': + if can_use_opensshbin and not module.params['passphrase']: + backend = 'opensshbin' + elif can_use_cryptography: + backend = 'cryptography' + else: + module.fail_json(msg="Cannot find either the OpenSSH binary in the PATH " + + "or cryptography >= 2.6 installed on this system") + + if backend == 'opensshbin': + if not can_use_opensshbin: + module.fail_json(msg="Cannot find the OpenSSH binary in the PATH") + return backend, KeypairBackendOpensshBin(module) + elif backend == 'cryptography': + if not can_use_cryptography: + module.fail_json(msg=missing_required_lib("cryptography >= 2.6")) + return backend, KeypairBackendCryptography(module) + else: + raise ValueError('Unsupported value for backend: {0}'.format(backend)) diff --git a/ansible_collections/community/crypto/plugins/module_utils/openssh/certificate.py b/ansible_collections/community/crypto/plugins/module_utils/openssh/certificate.py new file mode 100644 index 00000000..54d1b1ec --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/openssh/certificate.py @@ -0,0 +1,666 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2021, Andrew Pantuso (@ajpantuso) <ajpantuso@gmail.com> +# 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 + +# Protocol References +# ------------------- +# https://datatracker.ietf.org/doc/html/rfc4251 +# https://datatracker.ietf.org/doc/html/rfc4253 +# https://datatracker.ietf.org/doc/html/rfc5656 +# https://datatracker.ietf.org/doc/html/rfc8032 +# https://cvsweb.openbsd.org/src/usr.bin/ssh/PROTOCOL.certkeys?annotate=HEAD +# +# Inspired by: +# ------------ +# https://github.com/pyca/cryptography/blob/main/src/cryptography/hazmat/primitives/serialization/ssh.py +# https://github.com/paramiko/paramiko/blob/master/paramiko/message.py + +import abc +import binascii +import os +from base64 import b64encode +from datetime import datetime +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, +) + +# See https://cvsweb.openbsd.org/src/usr.bin/ssh/PROTOCOL.certkeys?annotate=HEAD +_USER_TYPE = 1 +_HOST_TYPE = 2 + +_SSH_TYPE_STRINGS = { + 'rsa': b"ssh-rsa", + 'dsa': b"ssh-dss", + 'ecdsa-nistp256': b"ecdsa-sha2-nistp256", + 'ecdsa-nistp384': b"ecdsa-sha2-nistp384", + 'ecdsa-nistp521': b"ecdsa-sha2-nistp521", + 'ed25519': b"ssh-ed25519", +} +_CERT_SUFFIX_V01 = b"-cert-v01@openssh.com" + +# See https://datatracker.ietf.org/doc/html/rfc5656#section-6.1 +_ECDSA_CURVE_IDENTIFIERS = { + 'ecdsa-nistp256': b'nistp256', + 'ecdsa-nistp384': b'nistp384', + 'ecdsa-nistp521': b'nistp521', +} +_ECDSA_CURVE_IDENTIFIERS_LOOKUP = { + b'nistp256': 'ecdsa-nistp256', + b'nistp384': 'ecdsa-nistp384', + b'nistp521': 'ecdsa-nistp521', +} + +_ALWAYS = datetime(1970, 1, 1) +_FOREVER = datetime.max + +_CRITICAL_OPTIONS = ( + 'force-command', + 'source-address', + 'verify-required', +) + +_DIRECTIVES = ( + 'clear', + 'no-x11-forwarding', + 'no-agent-forwarding', + 'no-port-forwarding', + 'no-pty', + 'no-user-rc', +) + +_EXTENSIONS = ( + 'permit-x11-forwarding', + 'permit-agent-forwarding', + 'permit-port-forwarding', + 'permit-pty', + 'permit-user-rc' +) + +if six.PY3: + long = int + + +class OpensshCertificateTimeParameters(object): + def __init__(self, valid_from, valid_to): + self._valid_from = self.to_datetime(valid_from) + self._valid_to = self.to_datetime(valid_to) + + if self._valid_from > self._valid_to: + raise ValueError("Valid from: %s must not be greater than Valid to: %s" % (valid_from, valid_to)) + + def __eq__(self, other): + if not isinstance(other, type(self)): + return NotImplemented + else: + return self._valid_from == other._valid_from and self._valid_to == other._valid_to + + def __ne__(self, other): + return not self == other + + @property + def validity_string(self): + if not (self._valid_from == _ALWAYS and self._valid_to == _FOREVER): + return "%s:%s" % ( + self.valid_from(date_format='openssh'), self.valid_to(date_format='openssh') + ) + return "" + + def valid_from(self, date_format): + return self.format_datetime(self._valid_from, date_format) + + def valid_to(self, date_format): + return self.format_datetime(self._valid_to, date_format) + + def within_range(self, valid_at): + if valid_at is not None: + valid_at_datetime = self.to_datetime(valid_at) + return self._valid_from <= valid_at_datetime <= self._valid_to + return True + + @staticmethod + def format_datetime(dt, date_format): + if date_format in ('human_readable', 'openssh'): + if dt == _ALWAYS: + result = 'always' + elif dt == _FOREVER: + result = 'forever' + else: + result = dt.isoformat() if date_format == 'human_readable' else dt.strftime("%Y%m%d%H%M%S") + elif date_format == 'timestamp': + td = dt - _ALWAYS + result = int((td.microseconds + (td.seconds + td.days * 24 * 3600) * 10 ** 6) / 10 ** 6) + else: + raise ValueError("%s is not a valid format" % date_format) + return result + + @staticmethod + def to_datetime(time_string_or_timestamp): + try: + if isinstance(time_string_or_timestamp, six.string_types): + result = OpensshCertificateTimeParameters._time_string_to_datetime(time_string_or_timestamp.strip()) + elif isinstance(time_string_or_timestamp, (long, int)): + result = OpensshCertificateTimeParameters._timestamp_to_datetime(time_string_or_timestamp) + else: + raise ValueError( + "Value must be of type (str, unicode, int, long) not %s" % type(time_string_or_timestamp) + ) + except ValueError: + raise + return result + + @staticmethod + def _timestamp_to_datetime(timestamp): + if timestamp == 0x0: + result = _ALWAYS + elif timestamp == 0xFFFFFFFFFFFFFFFF: + result = _FOREVER + else: + try: + result = datetime.utcfromtimestamp(timestamp) + except OverflowError as e: + raise ValueError + return result + + @staticmethod + def _time_string_to_datetime(time_string): + result = None + if time_string == 'always': + result = _ALWAYS + elif time_string == 'forever': + result = _FOREVER + elif is_relative_time_string(time_string): + result = convert_relative_to_datetime(time_string) + else: + for time_format in ("%Y-%m-%d", "%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"): + try: + result = datetime.strptime(time_string, time_format) + except ValueError: + pass + if result is None: + raise ValueError + return result + + +class OpensshCertificateOption(object): + def __init__(self, option_type, name, data): + if option_type not in ('critical', 'extension'): + raise ValueError("type must be either 'critical' or 'extension'") + + if not isinstance(name, six.string_types): + raise TypeError("name must be a string not %s" % type(name)) + + if not isinstance(data, six.string_types): + raise TypeError("data must be a string not %s" % type(data)) + + self._option_type = option_type + self._name = name.lower() + self._data = data + + def __eq__(self, other): + if not isinstance(other, type(self)): + return NotImplemented + + return all([ + self._option_type == other._option_type, + self._name == other._name, + self._data == other._data, + ]) + + def __hash__(self): + return hash((self._option_type, self._name, self._data)) + + def __ne__(self, other): + return not self == other + + def __str__(self): + if self._data: + return "%s=%s" % (self._name, self._data) + return self._name + + @property + def data(self): + return self._data + + @property + def name(self): + return self._name + + @property + def type(self): + return self._option_type + + @classmethod + def from_string(cls, option_string): + if not isinstance(option_string, six.string_types): + raise ValueError("option_string must be a string not %s" % type(option_string)) + option_type = None + + if ':' in option_string: + option_type, value = option_string.strip().split(':', 1) + if '=' in value: + name, data = value.split('=', 1) + else: + name, data = value, '' + elif '=' in option_string: + name, data = option_string.strip().split('=', 1) + else: + name, data = option_string.strip(), '' + + return cls( + option_type=option_type or get_option_type(name.lower()), + name=name, + data=data + ) + + +@six.add_metaclass(abc.ABCMeta) +class OpensshCertificateInfo: + """Encapsulates all certificate information which is signed by a CA key""" + def __init__(self, + nonce=None, + serial=None, + cert_type=None, + key_id=None, + principals=None, + valid_after=None, + valid_before=None, + critical_options=None, + extensions=None, + reserved=None, + signing_key=None): + self.nonce = nonce + self.serial = serial + self._cert_type = cert_type + self.key_id = key_id + self.principals = principals + self.valid_after = valid_after + self.valid_before = valid_before + self.critical_options = critical_options + self.extensions = extensions + self.reserved = reserved + self.signing_key = signing_key + + self.type_string = None + + @property + def cert_type(self): + if self._cert_type == _USER_TYPE: + return 'user' + elif self._cert_type == _HOST_TYPE: + return 'host' + else: + return '' + + @cert_type.setter + def cert_type(self, cert_type): + if cert_type == 'user' or cert_type == _USER_TYPE: + self._cert_type = _USER_TYPE + elif cert_type == 'host' or cert_type == _HOST_TYPE: + self._cert_type = _HOST_TYPE + else: + raise ValueError("%s is not a valid certificate type" % cert_type) + + def signing_key_fingerprint(self): + return fingerprint(self.signing_key) + + @abc.abstractmethod + def public_key_fingerprint(self): + pass + + @abc.abstractmethod + def parse_public_numbers(self, parser): + pass + + +class OpensshRSACertificateInfo(OpensshCertificateInfo): + def __init__(self, e=None, n=None, **kwargs): + super(OpensshRSACertificateInfo, self).__init__(**kwargs) + self.type_string = _SSH_TYPE_STRINGS['rsa'] + _CERT_SUFFIX_V01 + self.e = e + self.n = n + + # See https://datatracker.ietf.org/doc/html/rfc4253#section-6.6 + def public_key_fingerprint(self): + if any([self.e is None, self.n is None]): + return b'' + + writer = _OpensshWriter() + writer.string(_SSH_TYPE_STRINGS['rsa']) + writer.mpint(self.e) + writer.mpint(self.n) + + return fingerprint(writer.bytes()) + + def parse_public_numbers(self, parser): + self.e = parser.mpint() + self.n = parser.mpint() + + +class OpensshDSACertificateInfo(OpensshCertificateInfo): + def __init__(self, p=None, q=None, g=None, y=None, **kwargs): + super(OpensshDSACertificateInfo, self).__init__(**kwargs) + self.type_string = _SSH_TYPE_STRINGS['dsa'] + _CERT_SUFFIX_V01 + self.p = p + self.q = q + self.g = g + self.y = y + + # See https://datatracker.ietf.org/doc/html/rfc4253#section-6.6 + def public_key_fingerprint(self): + if any([self.p is None, self.q is None, self.g is None, self.y is None]): + return b'' + + writer = _OpensshWriter() + writer.string(_SSH_TYPE_STRINGS['dsa']) + writer.mpint(self.p) + writer.mpint(self.q) + writer.mpint(self.g) + writer.mpint(self.y) + + return fingerprint(writer.bytes()) + + def parse_public_numbers(self, parser): + self.p = parser.mpint() + self.q = parser.mpint() + self.g = parser.mpint() + self.y = parser.mpint() + + +class OpensshECDSACertificateInfo(OpensshCertificateInfo): + def __init__(self, curve=None, public_key=None, **kwargs): + super(OpensshECDSACertificateInfo, self).__init__(**kwargs) + self._curve = None + if curve is not None: + self.curve = curve + + self.public_key = public_key + + @property + def curve(self): + return self._curve + + @curve.setter + def curve(self, curve): + if curve in _ECDSA_CURVE_IDENTIFIERS.values(): + self._curve = curve + self.type_string = _SSH_TYPE_STRINGS[_ECDSA_CURVE_IDENTIFIERS_LOOKUP[curve]] + _CERT_SUFFIX_V01 + else: + raise ValueError( + "Curve must be one of %s" % (b','.join(list(_ECDSA_CURVE_IDENTIFIERS.values()))).decode('UTF-8') + ) + + # See https://datatracker.ietf.org/doc/html/rfc4253#section-6.6 + def public_key_fingerprint(self): + if any([self.curve is None, self.public_key is None]): + return b'' + + writer = _OpensshWriter() + writer.string(_SSH_TYPE_STRINGS[_ECDSA_CURVE_IDENTIFIERS_LOOKUP[self.curve]]) + writer.string(self.curve) + writer.string(self.public_key) + + return fingerprint(writer.bytes()) + + def parse_public_numbers(self, parser): + self.curve = parser.string() + self.public_key = parser.string() + + +class OpensshED25519CertificateInfo(OpensshCertificateInfo): + def __init__(self, pk=None, **kwargs): + super(OpensshED25519CertificateInfo, self).__init__(**kwargs) + self.type_string = _SSH_TYPE_STRINGS['ed25519'] + _CERT_SUFFIX_V01 + self.pk = pk + + def public_key_fingerprint(self): + if self.pk is None: + return b'' + + writer = _OpensshWriter() + writer.string(_SSH_TYPE_STRINGS['ed25519']) + writer.string(self.pk) + + return fingerprint(writer.bytes()) + + def parse_public_numbers(self, parser): + self.pk = parser.string() + + +# See https://cvsweb.openbsd.org/src/usr.bin/ssh/PROTOCOL.certkeys?annotate=HEAD +class OpensshCertificate(object): + """Encapsulates a formatted OpenSSH certificate including signature and signing key""" + def __init__(self, cert_info, signature): + + self._cert_info = cert_info + self.signature = signature + + @classmethod + def load(cls, path): + if not os.path.exists(path): + raise ValueError("%s is not a valid path." % path) + + try: + with open(path, 'rb') as cert_file: + data = cert_file.read() + except (IOError, OSError) as e: + raise ValueError("%s cannot be opened for reading: %s" % (path, e)) + + try: + format_identifier, b64_cert = data.split(b' ')[:2] + cert = binascii.a2b_base64(b64_cert) + except (binascii.Error, ValueError): + raise ValueError("Certificate not in OpenSSH format") + + for key_type, string in _SSH_TYPE_STRINGS.items(): + if format_identifier == string + _CERT_SUFFIX_V01: + pub_key_type = key_type + break + else: + raise ValueError("Invalid certificate format identifier: %s" % format_identifier) + + parser = OpensshParser(cert) + + if format_identifier != parser.string(): + raise ValueError("Certificate formats do not match") + + try: + cert_info = cls._parse_cert_info(pub_key_type, parser) + signature = parser.string() + except (TypeError, ValueError) as e: + raise ValueError("Invalid certificate data: %s" % e) + + if parser.remaining_bytes(): + raise ValueError( + "%s bytes of additional data was not parsed while loading %s" % (parser.remaining_bytes(), path) + ) + + return cls( + cert_info=cert_info, + signature=signature, + ) + + @property + def type_string(self): + return to_text(self._cert_info.type_string) + + @property + def nonce(self): + return self._cert_info.nonce + + @property + def public_key(self): + return to_text(self._cert_info.public_key_fingerprint()) + + @property + def serial(self): + return self._cert_info.serial + + @property + def type(self): + return self._cert_info.cert_type + + @property + def key_id(self): + return to_text(self._cert_info.key_id) + + @property + def principals(self): + return [to_text(p) for p in self._cert_info.principals] + + @property + def valid_after(self): + return self._cert_info.valid_after + + @property + def valid_before(self): + return self._cert_info.valid_before + + @property + def critical_options(self): + return [ + OpensshCertificateOption('critical', to_text(n), to_text(d)) for n, d in self._cert_info.critical_options + ] + + @property + def extensions(self): + return [OpensshCertificateOption('extension', to_text(n), to_text(d)) for n, d in self._cert_info.extensions] + + @property + def reserved(self): + return self._cert_info.reserved + + @property + def signing_key(self): + return to_text(self._cert_info.signing_key_fingerprint()) + + @property + def signature_type(self): + signature_data = OpensshParser.signature_data(self.signature) + return to_text(signature_data['signature_type']) + + @staticmethod + def _parse_cert_info(pub_key_type, parser): + cert_info = get_cert_info_object(pub_key_type) + cert_info.nonce = parser.string() + cert_info.parse_public_numbers(parser) + cert_info.serial = parser.uint64() + cert_info.cert_type = parser.uint32() + cert_info.key_id = parser.string() + cert_info.principals = parser.string_list() + cert_info.valid_after = parser.uint64() + cert_info.valid_before = parser.uint64() + cert_info.critical_options = parser.option_list() + cert_info.extensions = parser.option_list() + cert_info.reserved = parser.string() + cert_info.signing_key = parser.string() + + return cert_info + + def to_dict(self): + time_parameters = OpensshCertificateTimeParameters( + valid_from=self.valid_after, + valid_to=self.valid_before + ) + return { + 'type_string': self.type_string, + 'nonce': self.nonce, + 'serial': self.serial, + 'cert_type': self.type, + 'identifier': self.key_id, + 'principals': self.principals, + 'valid_after': time_parameters.valid_from(date_format='human_readable'), + 'valid_before': time_parameters.valid_to(date_format='human_readable'), + 'critical_options': [str(critical_option) for critical_option in self.critical_options], + 'extensions': [str(extension) for extension in self.extensions], + 'reserved': self.reserved, + 'public_key': self.public_key, + 'signing_key': self.signing_key, + } + + +def apply_directives(directives): + if any(d not in _DIRECTIVES for d in directives): + raise ValueError("directives must be one of %s" % ", ".join(_DIRECTIVES)) + + directive_to_option = { + 'no-x11-forwarding': OpensshCertificateOption('extension', 'permit-x11-forwarding', ''), + 'no-agent-forwarding': OpensshCertificateOption('extension', 'permit-agent-forwarding', ''), + 'no-port-forwarding': OpensshCertificateOption('extension', 'permit-port-forwarding', ''), + 'no-pty': OpensshCertificateOption('extension', 'permit-pty', ''), + 'no-user-rc': OpensshCertificateOption('extension', 'permit-user-rc', ''), + } + + if 'clear' in directives: + return [] + else: + return list(set(default_options()) - set(directive_to_option[d] for d in directives)) + + +def default_options(): + return [OpensshCertificateOption('extension', name, '') for name in _EXTENSIONS] + + +def fingerprint(public_key): + """Generates a SHA256 hash and formats output to resemble ``ssh-keygen``""" + h = sha256() + h.update(public_key) + return b'SHA256:' + b64encode(h.digest()).rstrip(b'=') + + +def get_cert_info_object(key_type): + if key_type == 'rsa': + cert_info = OpensshRSACertificateInfo() + elif key_type == 'dsa': + cert_info = OpensshDSACertificateInfo() + elif key_type in ('ecdsa-nistp256', 'ecdsa-nistp384', 'ecdsa-nistp521'): + cert_info = OpensshECDSACertificateInfo() + elif key_type == 'ed25519': + cert_info = OpensshED25519CertificateInfo() + else: + raise ValueError("%s is not a valid key type" % key_type) + + return cert_info + + +def get_option_type(name): + if name in _CRITICAL_OPTIONS: + result = 'critical' + elif name in _EXTENSIONS: + result = 'extension' + else: + raise ValueError("%s is not a valid option. " % name + + "Custom options must start with 'critical:' or 'extension:' to indicate type") + return result + + +def is_relative_time_string(time_string): + return time_string.startswith("+") or time_string.startswith("-") + + +def parse_option_list(option_list): + critical_options = [] + directives = [] + extensions = [] + + for option in option_list: + if option.lower() in _DIRECTIVES: + directives.append(option.lower()) + else: + option_object = OpensshCertificateOption.from_string(option) + if option_object.type == 'critical': + critical_options.append(option_object) + else: + extensions.append(option_object) + + return critical_options, list(set(extensions + apply_directives(directives))) diff --git a/ansible_collections/community/crypto/plugins/module_utils/openssh/cryptography.py b/ansible_collections/community/crypto/plugins/module_utils/openssh/cryptography.py new file mode 100644 index 00000000..69f3ce35 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/openssh/cryptography.py @@ -0,0 +1,685 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2021, Andrew Pantuso (@ajpantuso) <ajpantuso@gmail.com> +# 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 os +from base64 import b64encode, b64decode +from getpass import getuser +from socket import gethostname + +from ansible_collections.community.crypto.plugins.module_utils.version import LooseVersion + +try: + from cryptography import __version__ as CRYPTOGRAPHY_VERSION + from cryptography.exceptions import InvalidSignature, UnsupportedAlgorithm + from cryptography.hazmat.backends.openssl import backend + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import dsa, ec, rsa, padding + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey + + if LooseVersion(CRYPTOGRAPHY_VERSION) >= LooseVersion("3.0"): + HAS_OPENSSH_PRIVATE_FORMAT = True + else: + HAS_OPENSSH_PRIVATE_FORMAT = False + + HAS_OPENSSH_SUPPORT = True + + _ALGORITHM_PARAMETERS = { + 'rsa': { + 'default_size': 2048, + 'valid_sizes': range(1024, 16384), + 'signer_params': { + 'padding': padding.PSS( + mgf=padding.MGF1(hashes.SHA256()), + salt_length=padding.PSS.MAX_LENGTH, + ), + 'algorithm': hashes.SHA256(), + }, + }, + 'dsa': { + 'default_size': 1024, + 'valid_sizes': [1024], + 'signer_params': { + 'algorithm': hashes.SHA256(), + }, + }, + 'ed25519': { + 'default_size': 256, + 'valid_sizes': [256], + 'signer_params': {}, + }, + 'ecdsa': { + 'default_size': 256, + 'valid_sizes': [256, 384, 521], + 'signer_params': { + 'signature_algorithm': ec.ECDSA(hashes.SHA256()), + }, + 'curves': { + 256: ec.SECP256R1(), + 384: ec.SECP384R1(), + 521: ec.SECP521R1(), + } + } + } +except ImportError: + HAS_OPENSSH_PRIVATE_FORMAT = False + HAS_OPENSSH_SUPPORT = False + CRYPTOGRAPHY_VERSION = "0.0" + _ALGORITHM_PARAMETERS = {} + +_TEXT_ENCODING = 'UTF-8' + + +class OpenSSHError(Exception): + pass + + +class InvalidAlgorithmError(OpenSSHError): + pass + + +class InvalidCommentError(OpenSSHError): + pass + + +class InvalidDataError(OpenSSHError): + pass + + +class InvalidPrivateKeyFileError(OpenSSHError): + pass + + +class InvalidPublicKeyFileError(OpenSSHError): + pass + + +class InvalidKeyFormatError(OpenSSHError): + pass + + +class InvalidKeySizeError(OpenSSHError): + pass + + +class InvalidKeyTypeError(OpenSSHError): + pass + + +class InvalidPassphraseError(OpenSSHError): + pass + + +class InvalidSignatureError(OpenSSHError): + pass + + +class AsymmetricKeypair(object): + """Container for newly generated asymmetric key pairs or those loaded from existing files""" + + @classmethod + def generate(cls, keytype='rsa', size=None, passphrase=None): + """Returns an Asymmetric_Keypair object generated with the supplied parameters + or defaults to an unencrypted RSA-2048 key + + :keytype: One of rsa, dsa, ecdsa, ed25519 + :size: The key length for newly generated keys + :passphrase: Secret of type Bytes used to encrypt the private key being generated + """ + + if keytype not in _ALGORITHM_PARAMETERS.keys(): + raise InvalidKeyTypeError( + "%s is not a valid keytype. Valid keytypes are %s" % ( + keytype, ", ".join(_ALGORITHM_PARAMETERS.keys()) + ) + ) + + if not size: + size = _ALGORITHM_PARAMETERS[keytype]['default_size'] + else: + if size not in _ALGORITHM_PARAMETERS[keytype]['valid_sizes']: + raise InvalidKeySizeError( + "%s is not a valid key size for %s keys" % (size, keytype) + ) + + if passphrase: + encryption_algorithm = get_encryption_algorithm(passphrase) + else: + encryption_algorithm = serialization.NoEncryption() + + if keytype == 'rsa': + privatekey = rsa.generate_private_key( + # Public exponent should always be 65537 to prevent issues + # if improper padding is used during signing + public_exponent=65537, + key_size=size, + backend=backend, + ) + elif keytype == 'dsa': + privatekey = dsa.generate_private_key( + key_size=size, + backend=backend, + ) + elif keytype == 'ed25519': + privatekey = Ed25519PrivateKey.generate() + elif keytype == 'ecdsa': + privatekey = ec.generate_private_key( + _ALGORITHM_PARAMETERS['ecdsa']['curves'][size], + backend=backend, + ) + + publickey = privatekey.public_key() + + return cls( + keytype=keytype, + size=size, + privatekey=privatekey, + publickey=publickey, + encryption_algorithm=encryption_algorithm + ) + + @classmethod + def load(cls, path, passphrase=None, private_key_format='PEM', public_key_format='PEM', no_public_key=False): + """Returns an Asymmetric_Keypair object loaded from the supplied file path + + :path: A path to an existing private key to be loaded + :passphrase: Secret of type bytes used to decrypt the private key being loaded + :private_key_format: Format of private key to be loaded + :public_key_format: Format of public key to be loaded + :no_public_key: Set 'True' to only load a private key and automatically populate the matching public key + """ + + if passphrase: + encryption_algorithm = get_encryption_algorithm(passphrase) + else: + encryption_algorithm = serialization.NoEncryption() + + privatekey = load_privatekey(path, passphrase, private_key_format) + if no_public_key: + publickey = privatekey.public_key() + else: + publickey = load_publickey(path + '.pub', public_key_format) + + # Ed25519 keys are always of size 256 and do not have a key_size attribute + if isinstance(privatekey, Ed25519PrivateKey): + size = _ALGORITHM_PARAMETERS['ed25519']['default_size'] + else: + size = privatekey.key_size + + if isinstance(privatekey, rsa.RSAPrivateKey): + keytype = 'rsa' + elif isinstance(privatekey, dsa.DSAPrivateKey): + keytype = 'dsa' + elif isinstance(privatekey, ec.EllipticCurvePrivateKey): + keytype = 'ecdsa' + elif isinstance(privatekey, Ed25519PrivateKey): + keytype = 'ed25519' + else: + raise InvalidKeyTypeError("Key type '%s' is not supported" % type(privatekey)) + + return cls( + keytype=keytype, + size=size, + privatekey=privatekey, + publickey=publickey, + encryption_algorithm=encryption_algorithm + ) + + def __init__(self, keytype, size, privatekey, publickey, encryption_algorithm): + """ + :keytype: One of rsa, dsa, ecdsa, ed25519 + :size: The key length for the private key of this key pair + :privatekey: Private key object of this key pair + :publickey: Public key object of this key pair + :encryption_algorithm: Hashed secret used to encrypt the private key of this key pair + """ + + self.__size = size + self.__keytype = keytype + self.__privatekey = privatekey + self.__publickey = publickey + self.__encryption_algorithm = encryption_algorithm + + try: + self.verify(self.sign(b'message'), b'message') + except InvalidSignatureError: + raise InvalidPublicKeyFileError( + "The private key and public key of this keypair do not match" + ) + + def __eq__(self, other): + if not isinstance(other, AsymmetricKeypair): + return NotImplemented + + return (compare_publickeys(self.public_key, other.public_key) and + compare_encryption_algorithms(self.encryption_algorithm, other.encryption_algorithm)) + + def __ne__(self, other): + return not self == other + + @property + def private_key(self): + """Returns the private key of this key pair""" + + return self.__privatekey + + @property + def public_key(self): + """Returns the public key of this key pair""" + + return self.__publickey + + @property + def size(self): + """Returns the size of the private key of this key pair""" + + return self.__size + + @property + def key_type(self): + """Returns the key type of this key pair""" + + return self.__keytype + + @property + def encryption_algorithm(self): + """Returns the key encryption algorithm of this key pair""" + + return self.__encryption_algorithm + + def sign(self, data): + """Returns signature of data signed with the private key of this key pair + + :data: byteslike data to sign + """ + + try: + signature = self.__privatekey.sign( + data, + **_ALGORITHM_PARAMETERS[self.__keytype]['signer_params'] + ) + except TypeError as e: + raise InvalidDataError(e) + + return signature + + def verify(self, signature, data): + """Verifies that the signature associated with the provided data was signed + by the private key of this key pair. + + :signature: signature to verify + :data: byteslike data signed by the provided signature + """ + try: + return self.__publickey.verify( + signature, + data, + **_ALGORITHM_PARAMETERS[self.__keytype]['signer_params'] + ) + except InvalidSignature: + raise InvalidSignatureError + + def update_passphrase(self, passphrase=None): + """Updates the encryption algorithm of this key pair + + :passphrase: Byte secret used to encrypt this key pair + """ + + if passphrase: + self.__encryption_algorithm = get_encryption_algorithm(passphrase) + else: + self.__encryption_algorithm = serialization.NoEncryption() + + +class OpensshKeypair(object): + """Container for OpenSSH encoded asymmetric key pairs""" + + @classmethod + def generate(cls, keytype='rsa', size=None, passphrase=None, comment=None): + """Returns an Openssh_Keypair object generated using the supplied parameters or defaults to a RSA-2048 key + + :keytype: One of rsa, dsa, ecdsa, ed25519 + :size: The key length for newly generated keys + :passphrase: Secret of type Bytes used to encrypt the newly generated private key + :comment: Comment for a newly generated OpenSSH public key + """ + + if comment is None: + comment = "%s@%s" % (getuser(), gethostname()) + + asym_keypair = AsymmetricKeypair.generate(keytype, size, passphrase) + openssh_privatekey = cls.encode_openssh_privatekey(asym_keypair, 'SSH') + openssh_publickey = cls.encode_openssh_publickey(asym_keypair, comment) + fingerprint = calculate_fingerprint(openssh_publickey) + + return cls( + asym_keypair=asym_keypair, + openssh_privatekey=openssh_privatekey, + openssh_publickey=openssh_publickey, + fingerprint=fingerprint, + comment=comment, + ) + + @classmethod + def load(cls, path, passphrase=None, no_public_key=False): + """Returns an Openssh_Keypair object loaded from the supplied file path + + :path: A path to an existing private key to be loaded + :passphrase: Secret used to decrypt the private key being loaded + :no_public_key: Set 'True' to only load a private key and automatically populate the matching public key + """ + + if no_public_key: + comment = "" + else: + comment = extract_comment(path + '.pub') + + asym_keypair = AsymmetricKeypair.load(path, passphrase, 'SSH', 'SSH', no_public_key) + openssh_privatekey = cls.encode_openssh_privatekey(asym_keypair, 'SSH') + openssh_publickey = cls.encode_openssh_publickey(asym_keypair, comment) + fingerprint = calculate_fingerprint(openssh_publickey) + + return cls( + asym_keypair=asym_keypair, + openssh_privatekey=openssh_privatekey, + openssh_publickey=openssh_publickey, + fingerprint=fingerprint, + comment=comment, + ) + + @staticmethod + def encode_openssh_privatekey(asym_keypair, key_format): + """Returns an OpenSSH encoded private key for a given keypair + + :asym_keypair: Asymmetric_Keypair from the private key is extracted + :key_format: Format of the encoded private key. + """ + + if key_format == 'SSH': + # Default to PEM format if SSH not available + if not HAS_OPENSSH_PRIVATE_FORMAT: + privatekey_format = serialization.PrivateFormat.PKCS8 + else: + privatekey_format = serialization.PrivateFormat.OpenSSH + elif key_format == 'PKCS8': + privatekey_format = serialization.PrivateFormat.PKCS8 + elif key_format == 'PKCS1': + if asym_keypair.key_type == 'ed25519': + raise InvalidKeyFormatError("ed25519 keys cannot be represented in PKCS1 format") + privatekey_format = serialization.PrivateFormat.TraditionalOpenSSL + else: + raise InvalidKeyFormatError("The accepted private key formats are SSH, PKCS8, and PKCS1") + + encoded_privatekey = asym_keypair.private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=privatekey_format, + encryption_algorithm=asym_keypair.encryption_algorithm + ) + + return encoded_privatekey + + @staticmethod + def encode_openssh_publickey(asym_keypair, comment): + """Returns an OpenSSH encoded public key for a given keypair + + :asym_keypair: Asymmetric_Keypair from the public key is extracted + :comment: Comment to apply to the end of the returned OpenSSH encoded public key + """ + encoded_publickey = asym_keypair.public_key.public_bytes( + encoding=serialization.Encoding.OpenSSH, + format=serialization.PublicFormat.OpenSSH, + ) + + validate_comment(comment) + + encoded_publickey += (" %s" % comment).encode(encoding=_TEXT_ENCODING) if comment else b'' + + return encoded_publickey + + def __init__(self, asym_keypair, openssh_privatekey, openssh_publickey, fingerprint, comment): + """ + :asym_keypair: An Asymmetric_Keypair object from which the OpenSSH encoded keypair is derived + :openssh_privatekey: An OpenSSH encoded private key + :openssh_privatekey: An OpenSSH encoded public key + :fingerprint: The fingerprint of the OpenSSH encoded public key of this keypair + :comment: Comment applied to the OpenSSH public key of this keypair + """ + + self.__asym_keypair = asym_keypair + self.__openssh_privatekey = openssh_privatekey + self.__openssh_publickey = openssh_publickey + self.__fingerprint = fingerprint + self.__comment = comment + + def __eq__(self, other): + if not isinstance(other, OpensshKeypair): + return NotImplemented + + return self.asymmetric_keypair == other.asymmetric_keypair and self.comment == other.comment + + @property + def asymmetric_keypair(self): + """Returns the underlying asymmetric key pair of this OpenSSH encoded key pair""" + + return self.__asym_keypair + + @property + def private_key(self): + """Returns the OpenSSH formatted private key of this key pair""" + + return self.__openssh_privatekey + + @property + def public_key(self): + """Returns the OpenSSH formatted public key of this key pair""" + + return self.__openssh_publickey + + @property + def size(self): + """Returns the size of the private key of this key pair""" + + return self.__asym_keypair.size + + @property + def key_type(self): + """Returns the key type of this key pair""" + + return self.__asym_keypair.key_type + + @property + def fingerprint(self): + """Returns the fingerprint (SHA256 Hash) of the public key of this key pair""" + + return self.__fingerprint + + @property + def comment(self): + """Returns the comment applied to the OpenSSH formatted public key of this key pair""" + + return self.__comment + + @comment.setter + def comment(self, comment): + """Updates the comment applied to the OpenSSH formatted public key of this key pair + + :comment: Text to update the OpenSSH public key comment + """ + + validate_comment(comment) + + self.__comment = comment + encoded_comment = (" %s" % self.__comment).encode(encoding=_TEXT_ENCODING) if self.__comment else b'' + self.__openssh_publickey = b' '.join(self.__openssh_publickey.split(b' ', 2)[:2]) + encoded_comment + return self.__openssh_publickey + + def update_passphrase(self, passphrase): + """Updates the passphrase used to encrypt the private key of this keypair + + :passphrase: Text secret used for encryption + """ + + self.__asym_keypair.update_passphrase(passphrase) + self.__openssh_privatekey = OpensshKeypair.encode_openssh_privatekey(self.__asym_keypair, 'SSH') + + +def load_privatekey(path, passphrase, key_format): + privatekey_loaders = { + 'PEM': serialization.load_pem_private_key, + 'DER': serialization.load_der_private_key, + } + + # OpenSSH formatted private keys are not available in Cryptography <3.0 + if hasattr(serialization, 'load_ssh_private_key'): + privatekey_loaders['SSH'] = serialization.load_ssh_private_key + else: + privatekey_loaders['SSH'] = serialization.load_pem_private_key + + try: + privatekey_loader = privatekey_loaders[key_format] + except KeyError: + raise InvalidKeyFormatError( + "%s is not a valid key format (%s)" % ( + key_format, + ','.join(privatekey_loaders.keys()) + ) + ) + + if not os.path.exists(path): + raise InvalidPrivateKeyFileError("No file was found at %s" % path) + + try: + with open(path, 'rb') as f: + content = f.read() + + privatekey = privatekey_loader( + data=content, + password=passphrase, + backend=backend, + ) + + except ValueError as e: + # Revert to PEM if key could not be loaded in SSH format + if key_format == 'SSH': + try: + privatekey = privatekey_loaders['PEM']( + data=content, + password=passphrase, + backend=backend, + ) + except ValueError as e: + raise InvalidPrivateKeyFileError(e) + except TypeError as e: + raise InvalidPassphraseError(e) + except UnsupportedAlgorithm as e: + raise InvalidAlgorithmError(e) + else: + raise InvalidPrivateKeyFileError(e) + except TypeError as e: + raise InvalidPassphraseError(e) + except UnsupportedAlgorithm as e: + raise InvalidAlgorithmError(e) + + return privatekey + + +def load_publickey(path, key_format): + publickey_loaders = { + 'PEM': serialization.load_pem_public_key, + 'DER': serialization.load_der_public_key, + 'SSH': serialization.load_ssh_public_key, + } + + try: + publickey_loader = publickey_loaders[key_format] + except KeyError: + raise InvalidKeyFormatError( + "%s is not a valid key format (%s)" % ( + key_format, + ','.join(publickey_loaders.keys()) + ) + ) + + if not os.path.exists(path): + raise InvalidPublicKeyFileError("No file was found at %s" % path) + + try: + with open(path, 'rb') as f: + content = f.read() + + publickey = publickey_loader( + data=content, + backend=backend, + ) + except ValueError as e: + raise InvalidPublicKeyFileError(e) + except UnsupportedAlgorithm as e: + raise InvalidAlgorithmError(e) + + return publickey + + +def compare_publickeys(pk1, pk2): + a = isinstance(pk1, Ed25519PublicKey) + b = isinstance(pk2, Ed25519PublicKey) + if a or b: + if not a or not b: + return False + a = pk1.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) + b = pk2.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) + return a == b + else: + return pk1.public_numbers() == pk2.public_numbers() + + +def compare_encryption_algorithms(ea1, ea2): + if isinstance(ea1, serialization.NoEncryption) and isinstance(ea2, serialization.NoEncryption): + return True + elif (isinstance(ea1, serialization.BestAvailableEncryption) and + isinstance(ea2, serialization.BestAvailableEncryption)): + return ea1.password == ea2.password + else: + return False + + +def get_encryption_algorithm(passphrase): + try: + return serialization.BestAvailableEncryption(passphrase) + except ValueError as e: + raise InvalidPassphraseError(e) + + +def validate_comment(comment): + if not hasattr(comment, 'encode'): + raise InvalidCommentError("%s cannot be encoded to text" % comment) + + +def extract_comment(path): + + if not os.path.exists(path): + raise InvalidPublicKeyFileError("No file was found at %s" % path) + + try: + with open(path, 'rb') as f: + fields = f.read().split(b' ', 2) + if len(fields) == 3: + comment = fields[2].decode(_TEXT_ENCODING) + else: + comment = "" + except (IOError, OSError) as e: + raise InvalidPublicKeyFileError(e) + + return comment + + +def calculate_fingerprint(openssh_publickey): + digest = hashes.Hash(hashes.SHA256(), backend=backend) + decoded_pubkey = b64decode(openssh_publickey.split(b' ')[1]) + digest.update(decoded_pubkey) + + return 'SHA256:%s' % b64encode(digest.finalize()).decode(encoding=_TEXT_ENCODING).rstrip('=') diff --git a/ansible_collections/community/crypto/plugins/module_utils/openssh/utils.py b/ansible_collections/community/crypto/plugins/module_utils/openssh/utils.py new file mode 100644 index 00000000..0c3af8f2 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/openssh/utils.py @@ -0,0 +1,392 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2020, Doug Stanley <doug+ansible@technologixllc.com> +# Copyright (c) 2021, Andrew Pantuso (@ajpantuso) <ajpantuso@gmail.com> +# 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 os +import re +from contextlib import contextmanager +from struct import Struct + +from ansible.module_utils.six import PY3 + +# Protocol References +# ------------------- +# https://datatracker.ietf.org/doc/html/rfc4251 +# https://datatracker.ietf.org/doc/html/rfc4253 +# https://datatracker.ietf.org/doc/html/rfc5656 +# https://datatracker.ietf.org/doc/html/rfc8032 +# +# Inspired by: +# ------------ +# https://github.com/pyca/cryptography/blob/main/src/cryptography/hazmat/primitives/serialization/ssh.py +# https://github.com/paramiko/paramiko/blob/master/paramiko/message.py + +if PY3: + long = int + +# 0 (False) or 1 (True) encoded as a single byte +_BOOLEAN = Struct(b'?') +# Unsigned 8-bit integer in network-byte-order +_UBYTE = Struct(b'!B') +_UBYTE_MAX = 0xFF +# Unsigned 32-bit integer in network-byte-order +_UINT32 = Struct(b'!I') +# Unsigned 32-bit little endian integer +_UINT32_LE = Struct(b'<I') +_UINT32_MAX = 0xFFFFFFFF +# Unsigned 64-bit integer in network-byte-order +_UINT64 = Struct(b'!Q') +_UINT64_MAX = 0xFFFFFFFFFFFFFFFF + + +def any_in(sequence, *elements): + return any(e in sequence for e in elements) + + +def file_mode(path): + if not os.path.exists(path): + return 0o000 + return os.stat(path).st_mode & 0o777 + + +def parse_openssh_version(version_string): + """Parse the version output of ssh -V and return version numbers that can be compared""" + + parsed_result = re.match( + r"^.*openssh_(?P<version>[0-9.]+)(p?[0-9]+)[^0-9]*.*$", version_string.lower() + ) + if parsed_result is not None: + version = parsed_result.group("version").strip() + else: + version = None + + return version + + +@contextmanager +def secure_open(path, mode): + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode) + try: + yield fd + finally: + os.close(fd) + + +def secure_write(path, mode, content): + with secure_open(path, mode) as fd: + os.write(fd, content) + + +# See https://datatracker.ietf.org/doc/html/rfc4251#section-5 for SSH data types +class OpensshParser(object): + """Parser for OpenSSH encoded objects""" + BOOLEAN_OFFSET = 1 + UINT32_OFFSET = 4 + UINT64_OFFSET = 8 + + def __init__(self, data): + if not isinstance(data, (bytes, bytearray)): + raise TypeError("Data must be bytes-like not %s" % type(data)) + + self._data = memoryview(data) if PY3 else data + self._pos = 0 + + def boolean(self): + next_pos = self._check_position(self.BOOLEAN_OFFSET) + + value = _BOOLEAN.unpack(self._data[self._pos:next_pos])[0] + self._pos = next_pos + return value + + def uint32(self): + next_pos = self._check_position(self.UINT32_OFFSET) + + value = _UINT32.unpack(self._data[self._pos:next_pos])[0] + self._pos = next_pos + return value + + def uint64(self): + next_pos = self._check_position(self.UINT64_OFFSET) + + value = _UINT64.unpack(self._data[self._pos:next_pos])[0] + self._pos = next_pos + return value + + def string(self): + length = self.uint32() + + next_pos = self._check_position(length) + + value = self._data[self._pos:next_pos] + self._pos = next_pos + # Cast to bytes is required as a memoryview slice is itself a memoryview + return value if not PY3 else bytes(value) + + def mpint(self): + return self._big_int(self.string(), "big", signed=True) + + def name_list(self): + raw_string = self.string() + return raw_string.decode('ASCII').split(',') + + # Convenience function, but not an official data type from SSH + def string_list(self): + result = [] + raw_string = self.string() + + if raw_string: + parser = OpensshParser(raw_string) + while parser.remaining_bytes(): + result.append(parser.string()) + + return result + + # Convenience function, but not an official data type from SSH + def option_list(self): + result = [] + raw_string = self.string() + + if raw_string: + parser = OpensshParser(raw_string) + + while parser.remaining_bytes(): + name = parser.string() + data = parser.string() + if data: + # data is doubly-encoded + data = OpensshParser(data).string() + result.append((name, data)) + + return result + + def seek(self, offset): + self._pos = self._check_position(offset) + + return self._pos + + def remaining_bytes(self): + return len(self._data) - self._pos + + def _check_position(self, offset): + if self._pos + offset > len(self._data): + raise ValueError("Insufficient data remaining at position: %s" % self._pos) + elif self._pos + offset < 0: + raise ValueError("Position cannot be less than zero.") + else: + return self._pos + offset + + @classmethod + def signature_data(cls, signature_string): + signature_data = {} + + parser = cls(signature_string) + signature_type = parser.string() + signature_blob = parser.string() + + blob_parser = cls(signature_blob) + if signature_type in (b'ssh-rsa', b'rsa-sha2-256', b'rsa-sha2-512'): + # https://datatracker.ietf.org/doc/html/rfc4253#section-6.6 + # https://datatracker.ietf.org/doc/html/rfc8332#section-3 + signature_data['s'] = cls._big_int(signature_blob, "big") + elif signature_type == b'ssh-dss': + # https://datatracker.ietf.org/doc/html/rfc4253#section-6.6 + signature_data['r'] = cls._big_int(signature_blob[:20], "big") + signature_data['s'] = cls._big_int(signature_blob[20:], "big") + elif signature_type in (b'ecdsa-sha2-nistp256', b'ecdsa-sha2-nistp384', b'ecdsa-sha2-nistp521'): + # https://datatracker.ietf.org/doc/html/rfc5656#section-3.1.2 + signature_data['r'] = blob_parser.mpint() + signature_data['s'] = blob_parser.mpint() + elif signature_type == b'ssh-ed25519': + # https://datatracker.ietf.org/doc/html/rfc8032#section-5.1.2 + signature_data['R'] = cls._big_int(signature_blob[:32], "little") + signature_data['S'] = cls._big_int(signature_blob[32:], "little") + else: + raise ValueError("%s is not a valid signature type" % signature_type) + + signature_data['signature_type'] = signature_type + + return signature_data + + @classmethod + def _big_int(cls, raw_string, byte_order, signed=False): + if byte_order not in ("big", "little"): + raise ValueError("Byte_order must be one of (big, little) not %s" % byte_order) + + if PY3: + return int.from_bytes(raw_string, byte_order, signed=signed) + + result = 0 + byte_length = len(raw_string) + + if byte_length > 0: + # Check sign-bit + msb = raw_string[0] if byte_order == "big" else raw_string[-1] + negative = bool(ord(msb) & 0x80) + # Match pad value for two's complement + pad = b'\xFF' if signed and negative else b'\x00' + # The definition of ``mpint`` enforces that unnecessary bytes are not encoded so they are added back + pad_length = (4 - byte_length % 4) + if pad_length < 4: + raw_string = pad * pad_length + raw_string if byte_order == "big" else raw_string + pad * pad_length + byte_length += pad_length + # Accumulate arbitrary precision integer bytes in the appropriate order + if byte_order == "big": + for i in range(0, byte_length, cls.UINT32_OFFSET): + left_shift = result << cls.UINT32_OFFSET * 8 + result = left_shift + _UINT32.unpack(raw_string[i:i + cls.UINT32_OFFSET])[0] + else: + for i in range(byte_length, 0, -cls.UINT32_OFFSET): + left_shift = result << cls.UINT32_OFFSET * 8 + result = left_shift + _UINT32_LE.unpack(raw_string[i - cls.UINT32_OFFSET:i])[0] + # Adjust for two's complement + if signed and negative: + result -= 1 << (8 * byte_length) + + return result + + +class _OpensshWriter(object): + """Writes SSH encoded values to a bytes-like buffer + + .. warning:: + This class is a private API and must not be exported outside of the openssh module_utils. + It is not to be used to construct Openssh objects, but rather as a utility to assist + in validating parsed material. + """ + def __init__(self, buffer=None): + if buffer is not None: + if not isinstance(buffer, (bytes, bytearray)): + raise TypeError("Buffer must be a bytes-like object not %s" % type(buffer)) + else: + buffer = bytearray() + + self._buff = buffer + + def boolean(self, value): + if not isinstance(value, bool): + raise TypeError("Value must be of type bool not %s" % type(value)) + + self._buff.extend(_BOOLEAN.pack(value)) + + return self + + def uint32(self, value): + if not isinstance(value, int): + raise TypeError("Value must be of type int not %s" % type(value)) + if value < 0 or value > _UINT32_MAX: + raise ValueError("Value must be a positive integer less than %s" % _UINT32_MAX) + + self._buff.extend(_UINT32.pack(value)) + + return self + + def uint64(self, value): + if not isinstance(value, (long, int)): + raise TypeError("Value must be of type (long, int) not %s" % type(value)) + if value < 0 or value > _UINT64_MAX: + raise ValueError("Value must be a positive integer less than %s" % _UINT64_MAX) + + self._buff.extend(_UINT64.pack(value)) + + return self + + def string(self, value): + if not isinstance(value, (bytes, bytearray)): + raise TypeError("Value must be bytes-like not %s" % type(value)) + self.uint32(len(value)) + self._buff.extend(value) + + return self + + def mpint(self, value): + if not isinstance(value, (int, long)): + raise TypeError("Value must be of type (long, int) not %s" % type(value)) + + self.string(self._int_to_mpint(value)) + + return self + + def name_list(self, value): + if not isinstance(value, list): + raise TypeError("Value must be a list of byte strings not %s" % type(value)) + + try: + self.string(','.join(value).encode('ASCII')) + except UnicodeEncodeError as e: + raise ValueError("Name-list's must consist of US-ASCII characters: %s" % e) + + return self + + def string_list(self, value): + if not isinstance(value, list): + raise TypeError("Value must be a list of byte string not %s" % type(value)) + + writer = _OpensshWriter() + for s in value: + writer.string(s) + + self.string(writer.bytes()) + + return self + + def option_list(self, value): + if not isinstance(value, list) or (value and not isinstance(value[0], tuple)): + raise TypeError("Value must be a list of tuples") + + writer = _OpensshWriter() + for name, data in value: + writer.string(name) + # SSH option data is encoded twice though this behavior is not documented + writer.string(_OpensshWriter().string(data).bytes() if data else bytes()) + + self.string(writer.bytes()) + + return self + + @staticmethod + def _int_to_mpint(num): + if PY3: + byte_length = (num.bit_length() + 7) // 8 + try: + result = num.to_bytes(byte_length, "big", signed=True) + # Handles values which require \x00 or \xFF to pad sign-bit + except OverflowError: + result = num.to_bytes(byte_length + 1, "big", signed=True) + else: + result = bytes() + # 0 and -1 are treated as special cases since they are used as sentinels for all other values + if num == 0: + result += b'\x00' + elif num == -1: + result += b'\xFF' + elif num > 0: + while num >> 32: + result = _UINT32.pack(num & _UINT32_MAX) + result + num = num >> 32 + # Pack last 4 bytes individually to discard insignificant bytes + while num: + result = _UBYTE.pack(num & _UBYTE_MAX) + result + num = num >> 8 + # Zero pad final byte if most-significant bit is 1 as per mpint definition + if ord(result[0]) & 0x80: + result = b'\x00' + result + else: + while (num >> 32) < -1: + result = _UINT32.pack(num & _UINT32_MAX) + result + num = num >> 32 + while num < -1: + result = _UBYTE.pack(num & _UBYTE_MAX) + result + num = num >> 8 + if not ord(result[0]) & 0x80: + result = b'\xFF' + result + + return result + + def bytes(self): + return bytes(self._buff) diff --git a/ansible_collections/community/crypto/plugins/module_utils/version.py b/ansible_collections/community/crypto/plugins/module_utils/version.py new file mode 100644 index 00000000..dc01ffe8 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/version.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2021, 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 + +"""Provide version object to compare version numbers.""" + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +# Once we drop support for Ansible 2.9, ansible-base 2.10, and ansible-core 2.11, we can +# remove the _version.py file, and replace the following import by +# +# from ansible.module_utils.compat.version import LooseVersion + +from ._version import LooseVersion # noqa: F401, pylint: disable=unused-import diff --git a/ansible_collections/community/crypto/plugins/modules/acme_account.py b/ansible_collections/community/crypto/plugins/modules/acme_account.py new file mode 100644 index 00000000..13de49ab --- /dev/null +++ b/ansible_collections/community/crypto/plugins/modules/acme_account.py @@ -0,0 +1,345 @@ +#!/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_account +author: "Felix Fontein (@felixfontein)" +short_description: Create, modify or delete ACME accounts +description: + - "Allows to create, modify or delete accounts with a CA supporting the + L(ACME protocol,https://tools.ietf.org/html/rfc8555), + such as L(Let's Encrypt,https://letsencrypt.org/)." + - "This module only works with the ACME v2 protocol." +notes: + - "The M(community.crypto.acme_certificate) module also allows to do basic account management. + When using both modules, it is recommended to disable account management + for M(community.crypto.acme_certificate). For that, use the C(modify_account) option of + M(community.crypto.acme_certificate)." +seealso: + - name: Automatic Certificate Management Environment (ACME) + description: The specification of the ACME protocol (RFC 8555). + link: https://tools.ietf.org/html/rfc8555 + - module: community.crypto.acme_account_info + description: Retrieves facts about an ACME account. + - module: community.crypto.openssl_privatekey + description: Can be used to create a private account key. + - module: community.crypto.openssl_privatekey_pipe + description: Can be used to create a private account key without writing it to disk. + - module: community.crypto.acme_inspect + description: Allows to debug problems. +extends_documentation_fragment: + - community.crypto.acme + - community.crypto.attributes + - community.crypto.attributes.actiongroup_acme +attributes: + check_mode: + support: full + diff_mode: + support: full +options: + state: + description: + - "The state of the account, to be identified by its account key." + - "If the state is C(absent), the account will either not exist or be + deactivated." + - "If the state is C(changed_key), the account must exist. The account + key will be changed; no other information will be touched." + type: str + required: true + choices: + - present + - absent + - changed_key + allow_creation: + description: + - "Whether account creation is allowed (when state is C(present))." + type: bool + default: true + contact: + description: + - "A list of contact URLs." + - "Email addresses must be prefixed with C(mailto:)." + - "See U(https://tools.ietf.org/html/rfc8555#section-7.3) + for what is allowed." + - "Must be specified when state is C(present). Will be ignored + if state is C(absent) or C(changed_key)." + type: list + elements: str + default: [] + terms_agreed: + description: + - "Boolean indicating whether you agree to the terms of service document." + - "ACME servers can require this to be true." + type: bool + default: false + new_account_key_src: + description: + - "Path to a file containing the ACME account RSA or Elliptic Curve key to change to." + - "Same restrictions apply as to C(account_key_src)." + - "Mutually exclusive with C(new_account_key_content)." + - "Required if C(new_account_key_content) is not used and state is C(changed_key)." + type: path + new_account_key_content: + description: + - "Content of the ACME account RSA or Elliptic Curve key to change to." + - "Same restrictions apply as to C(account_key_content)." + - "Mutually exclusive with C(new_account_key_src)." + - "Required if C(new_account_key_src) is not used and state is C(changed_key)." + type: str + new_account_key_passphrase: + description: + - Phassphrase to use to decode the new 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 + external_account_binding: + description: + - Allows to provide external account binding data during account creation. + - This is used by CAs like Sectigo to bind a new ACME account to an existing CA-specific + account, to be able to properly identify a customer. + - Only used when creating a new account. Can not be specified for ACME v1. + type: dict + suboptions: + kid: + description: + - The key identifier provided by the CA. + type: str + required: true + alg: + description: + - The MAC algorithm provided by the CA. + - If not specified by the CA, this is probably C(HS256). + type: str + required: true + choices: [ HS256, HS384, HS512 ] + key: + description: + - Base64 URL encoded value of the MAC key provided by the CA. + - Padding (C(=) symbols at the end) can be omitted. + type: str + required: true + version_added: 1.1.0 +''' + +EXAMPLES = ''' +- name: Make sure account exists and has given contacts. We agree to TOS. + community.crypto.acme_account: + account_key_src: /etc/pki/cert/private/account.key + state: present + terms_agreed: true + contact: + - mailto:me@example.com + - mailto:myself@example.org + +- name: Make sure account has given email address. Do not create account if it does not exist + community.crypto.acme_account: + account_key_src: /etc/pki/cert/private/account.key + state: present + allow_creation: false + contact: + - mailto:me@example.com + +- name: Change account's key to the one stored in the variable new_account_key + community.crypto.acme_account: + account_key_src: /etc/pki/cert/private/account.key + new_account_key_content: '{{ new_account_key }}' + state: changed_key + +- name: Delete account (we have to use the new key) + community.crypto.acme_account: + account_key_content: '{{ new_account_key }}' + state: absent +''' + +RETURN = ''' +account_uri: + description: ACME account URI, or None if account does not exist. + returned: always + type: str +''' + +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, + 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, + KeyParsingError, +) + + +def main(): + argument_spec = get_default_argspec() + argument_spec.update(dict( + 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), + contact=dict(type='list', elements='str', default=[]), + new_account_key_src=dict(type='path'), + new_account_key_content=dict(type='str', no_log=True), + new_account_key_passphrase=dict(type='str', no_log=True), + external_account_binding=dict(type='dict', options=dict( + kid=dict(type='str', required=True), + 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'], + ), + mutually_exclusive=( + ['account_key_src', 'account_key_content'], + ['new_account_key_src', 'new_account_key_content'], + ), + required_if=( + # Make sure that for state == changed_key, one of + # 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, + ) + backend = create_backend(module, True) + + if module.params['external_account_binding']: + # Make sure padding is there + key = module.params['external_account_binding']['key'] + if len(key) % 4 != 0: + key = key + ('=' * (4 - (len(key) % 4))) + # Make sure key is Base64 encoded + try: + base64.urlsafe_b64decode(key) + except Exception as e: + module.fail_json(msg='Key for external_account_binding must be Base64 URL encoded (%s)' % e) + module.params['external_account_binding']['key'] = key + + try: + client = ACMEClient(module, backend) + account = ACMEAccount(client) + changed = False + state = module.params.get('state') + diff_before = {} + diff_after = {} + if state == 'absent': + created, account_data = account.setup_account(allow_creation=False) + if account_data: + diff_before = dict(account_data) + diff_before['public_account_key'] = client.account_key_data['jwk'] + if created: + raise AssertionError('Unwanted account creation') + if account_data is not None: + # Account is not yet deactivated + if not module.check_mode: + # Deactivate it + payload = { + 'status': 'deactivated' + } + result, info = client.send_signed_request( + client.account_uri, payload, error_msg='Failed to deactivate account', expected_status_codes=[200]) + changed = True + elif state == 'present': + allow_creation = module.params.get('allow_creation') + contact = [str(v) for v in module.params.get('contact')] + terms_agreed = module.params.get('terms_agreed') + external_account_binding = module.params.get('external_account_binding') + created, account_data = account.setup_account( + contact, + terms_agreed=terms_agreed, + allow_creation=allow_creation, + external_account_binding=external_account_binding, + ) + if account_data is None: + raise ModuleFailException(msg='Account does not exist or is deactivated.') + if created: + diff_before = {} + else: + diff_before = dict(account_data) + diff_before['public_account_key'] = client.account_key_data['jwk'] + updated = False + if not created: + updated, account_data = account.update_account(account_data, contact) + changed = created or updated + diff_after = dict(account_data) + diff_after['public_account_key'] = client.account_key_data['jwk'] + elif state == 'changed_key': + # Parse new account key + try: + new_key_data = client.parse_key( + module.params.get('new_account_key_src'), + module.params.get('new_account_key_content'), + passphrase=module.params.get('new_account_key_passphrase'), + ) + except KeyParsingError as e: + raise ModuleFailException("Error while parsing new account key: {msg}".format(msg=e.msg)) + # Verify that the account exists and has not been deactivated + created, account_data = account.setup_account(allow_creation=False) + if created: + raise AssertionError('Unwanted account creation') + if account_data is None: + raise ModuleFailException(msg='Account does not exist or is deactivated.') + diff_before = dict(account_data) + diff_before['public_account_key'] = client.account_key_data['jwk'] + # Now we can start the account key rollover + if not module.check_mode: + # Compose inner signed message + # https://tools.ietf.org/html/rfc8555#section-7.3.5 + url = client.directory['keyChange'] + protected = { + "alg": new_key_data['alg'], + "jwk": new_key_data['jwk'], + "url": url, + } + payload = { + "account": client.account_uri, + "newKey": new_key_data['jwk'], # specified in draft 12 and older + "oldKey": client.account_jwk, # specified in draft 13 and newer + } + data = client.sign_request(protected, payload, new_key_data) + # Send request and verify result + result, info = client.send_signed_request( + url, data, error_msg='Failed to rollover account key', expected_status_codes=[200]) + if module._diff: + client.account_key_data = new_key_data + client.account_jws_header['alg'] = new_key_data['alg'] + diff_after = account.get_account_data() + elif module._diff: + # Kind of fake diff_after + diff_after = dict(diff_before) + diff_after['public_account_key'] = new_key_data['jwk'] + changed = True + result = { + 'changed': changed, + 'account_uri': client.account_uri, + } + if module._diff: + result['diff'] = { + 'before': diff_before, + 'after': diff_after, + } + module.exit_json(**result) + except ModuleFailException as e: + e.do_fail(module) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/crypto/plugins/modules/acme_account_info.py b/ansible_collections/community/crypto/plugins/modules/acme_account_info.py new file mode 100644 index 00000000..3f240649 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/modules/acme_account_info.py @@ -0,0 +1,320 @@ +#!/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_account_info +author: "Felix Fontein (@felixfontein)" +short_description: Retrieves information on ACME accounts +description: + - "Allows to retrieve information on accounts a CA supporting the + L(ACME protocol,https://tools.ietf.org/html/rfc8555), + such as L(Let's Encrypt,https://letsencrypt.org/)." + - "This module only works with the ACME v2 protocol." +notes: + - "The M(community.crypto.acme_account) module allows to modify, create and delete ACME + accounts." + - "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.attributes + - community.crypto.attributes.actiongroup_acme + - community.crypto.attributes.info_module +options: + retrieve_orders: + description: + - "Whether to retrieve the list of order URLs or order objects, if provided + by the ACME server." + - "A value of C(ignore) will not fetch the list of orders." + - "If the value is not C(ignore) and the ACME server supports orders, the C(order_uris) + return value is always populated. The C(orders) return value is only returned + if this option is set to C(object_list)." + - "Currently, Let's Encrypt does not return orders, so the C(orders) result + will always be empty." + type: str + choices: + - ignore + - url_list + - object_list + default: ignore +seealso: + - module: community.crypto.acme_account + description: Allows to create, modify or delete an ACME account. + +''' + +EXAMPLES = ''' +- name: Check whether an account with the given account key exists + community.crypto.acme_account_info: + account_key_src: /etc/pki/cert/private/account.key + register: account_data +- name: Verify that account exists + assert: + that: + - account_data.exists +- name: Print account URI + ansible.builtin.debug: + var: account_data.account_uri +- name: Print account contacts + ansible.builtin.debug: + var: account_data.account.contact + +- name: Check whether the account exists and is accessible with the given account key + acme_account_info: + account_key_content: "{{ acme_account_key }}" + account_uri: "{{ acme_account_uri }}" + register: account_data +- name: Verify that account exists + assert: + that: + - account_data.exists +- name: Print account contacts + ansible.builtin.debug: + var: account_data.account.contact +''' + +RETURN = ''' +exists: + description: Whether the account exists. + returned: always + type: bool + +account_uri: + description: ACME account URI, or None if account does not exist. + returned: always + type: str + +account: + description: The account information, as retrieved from the ACME server. + returned: if account exists + type: dict + contains: + contact: + description: the challenge resource that must be created for validation + returned: always + type: list + elements: str + sample: ['mailto:me@example.com', 'tel:00123456789'] + status: + description: the account's status + returned: always + type: str + choices: ['valid', 'deactivated', 'revoked'] + sample: valid + orders: + description: + - A URL where a list of orders can be retrieved for this account. + - Use the I(retrieve_orders) option to query this URL and retrieve the + complete list of orders. + returned: always + type: str + sample: https://example.ca/account/1/orders + public_account_key: + description: the public account key as a L(JSON Web Key,https://tools.ietf.org/html/rfc7517). + returned: always + type: str + sample: '{"kty":"EC","crv":"P-256","x":"MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4","y":"4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM"}' + +orders: + description: + - "The list of orders." + type: list + elements: dict + returned: if account exists, I(retrieve_orders) is C(object_list), and server supports order listing + contains: + status: + description: The order's status. + type: str + choices: + - pending + - ready + - processing + - valid + - invalid + expires: + description: + - When the order expires. + - Timestamp should be formatted as described in RFC3339. + - Only required to be included in result when I(status) is C(pending) or C(valid). + type: str + returned: when server gives expiry date + identifiers: + description: + - List of identifiers this order is for. + type: list + elements: dict + contains: + type: + description: Type of identifier. C(dns) or C(ip). + type: str + value: + description: Name of identifier. Hostname or IP address. + type: str + wildcard: + description: "Whether I(value) is actually a wildcard. The wildcard + prefix C(*.) is not included in I(value) if this is C(true)." + type: bool + returned: required to be included if the identifier is wildcarded + notBefore: + description: + - The requested value of the C(notBefore) field in the certificate. + - Date should be formatted as described in RFC3339. + - Server is not required to return this. + type: str + returned: when server returns this + notAfter: + description: + - The requested value of the C(notAfter) field in the certificate. + - Date should be formatted as described in RFC3339. + - Server is not required to return this. + type: str + returned: when server returns this + error: + description: + - In case an error occurred during processing, this contains information about the error. + - The field is structured as a problem document (RFC7807). + type: dict + returned: when an error occurred + authorizations: + description: + - A list of URLs for authorizations for this order. + type: list + elements: str + finalize: + description: + - A URL used for finalizing an ACME order. + type: str + certificate: + description: + - The URL for retrieving the certificate. + type: str + returned: when certificate was issued + +order_uris: + description: + - "The list of orders." + - "If I(retrieve_orders) is C(url_list), this will be a list of URLs." + - "If I(retrieve_orders) is C(object_list), this will be a list of objects." + type: list + elements: str + returned: if account exists, I(retrieve_orders) is not C(ignore), and server supports order listing + 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, + 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.utils import ( + process_links, +) + + +def get_orders_list(module, client, orders_url): + ''' + Retrieves orders list (handles pagination). + ''' + orders = [] + while orders_url: + # Get part of orders list + res, info = client.get_request(orders_url, parse_json_result=True, fail_on_error=True) + if not res.get('orders'): + if orders: + module.warn('When retrieving orders list part {0}, got empty result list'.format(orders_url)) + break + # Add order URLs to result list + orders.extend(res['orders']) + # Extract URL of next part of results list + new_orders_url = [] + + def f(link, relation): + if relation == 'next': + new_orders_url.append(link) + + process_links(info, f) + new_orders_url.append(None) + previous_orders_url, orders_url = orders_url, new_orders_url.pop(0) + if orders_url == previous_orders_url: + # Prevent infinite loop + orders_url = None + return orders + + +def get_order(client, order_url): + ''' + Retrieve order data. + ''' + return client.get_request(order_url, parse_json_result=True, fail_on_error=True)[0] + + +def main(): + argument_spec = get_default_argspec() + argument_spec.update(dict( + 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, + ) + backend = create_backend(module, True) + + try: + client = ACMEClient(module, backend) + account = ACMEAccount(client) + # Check whether account exists + created, account_data = account.setup_account( + [], + allow_creation=False, + remove_account_uri_if_not_exists=True, + ) + if created: + raise AssertionError('Unwanted account creation') + result = { + 'changed': False, + 'exists': client.account_uri is not None, + 'account_uri': client.account_uri, + } + if client.account_uri is not None: + # Make sure promised data is there + if 'contact' not in account_data: + account_data['contact'] = [] + account_data['public_account_key'] = client.account_key_data['jwk'] + result['account'] = account_data + # Retrieve orders list + if account_data.get('orders') and module.params['retrieve_orders'] != 'ignore': + orders = get_orders_list(module, client, account_data['orders']) + result['order_uris'] = orders + if module.params['retrieve_orders'] == 'object_list': + result['orders'] = [get_order(client, order) for order in orders] + module.exit_json(**result) + 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 new file mode 100644 index 00000000..274ed1d2 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/modules/acme_certificate.py @@ -0,0 +1,919 @@ +#!/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 +author: "Michael Gruener (@mgruener)" +short_description: Create SSL/TLS certificates with the ACME protocol +description: + - "Create and renew SSL/TLS certificates with a CA supporting the + L(ACME protocol,https://tools.ietf.org/html/rfc8555), + such as L(Let's Encrypt,https://letsencrypt.org/) or + L(Buypass,https://www.buypass.com/). The current implementation + supports the C(http-01), C(dns-01) and C(tls-alpn-01) challenges." + - "To use this module, it has to be executed twice. Either as two + different tasks in the same run or during two runs. Note that the output + of the first run needs to be recorded and passed to the second run as the + module argument C(data)." + - "Between these two tasks you have to fulfill the required steps for the + chosen challenge by whatever means necessary. For C(http-01) that means + creating the necessary challenge file on the destination webserver. For + C(dns-01) the necessary dns record has to be created. For C(tls-alpn-01) + the necessary certificate has to be created and served. + It is I(not) the responsibility of this module to perform these steps." + - "For details on how to fulfill these challenges, you might have to read through + L(the main ACME specification,https://tools.ietf.org/html/rfc8555#section-8) + and the L(TLS-ALPN-01 specification,https://www.rfc-editor.org/rfc/rfc8737.html#section-3). + Also, consider the examples provided for this module." + - "The module includes experimental support for IP identifiers according to + the L(RFC 8738,https://www.rfc-editor.org/rfc/rfc8738.html)." +notes: + - "At least one of C(dest) and C(fullchain_dest) must be specified." + - "This module includes basic account management functionality. + If you want to have more control over your ACME account, use the + M(community.crypto.acme_account) module and disable account management + for this module using the C(modify_account) option." + - "This module was called C(letsencrypt) before Ansible 2.6. The usage + did not change." +seealso: + - name: The Let's Encrypt documentation + description: Documentation for the Let's Encrypt Certification Authority. + Provides useful information for example on rate limits. + link: https://letsencrypt.org/docs/ + - name: Buypass Go SSL + description: Documentation for the Buypass Certification Authority. + Provides useful information for example on rate limits. + link: https://www.buypass.com/ssl/products/acme + - name: Automatic Certificate Management Environment (ACME) + description: The specification of the ACME protocol (RFC 8555). + link: https://tools.ietf.org/html/rfc8555 + - name: ACME TLS ALPN Challenge Extension + description: The specification of the C(tls-alpn-01) challenge (RFC 8737). + link: https://www.rfc-editor.org/rfc/rfc8737.html-05 + - module: community.crypto.acme_challenge_cert_helper + description: Helps preparing C(tls-alpn-01) challenges. + - module: community.crypto.openssl_privatekey + description: Can be used to create private keys (both for certificates and accounts). + - module: community.crypto.openssl_privatekey_pipe + description: Can be used to create private keys without writing it to disk (both for certificates and accounts). + - module: community.crypto.openssl_csr + description: Can be used to create a Certificate Signing Request (CSR). + - module: community.crypto.openssl_csr_pipe + description: Can be used to create a Certificate Signing Request (CSR) without writing it to disk. + - module: community.crypto.certificate_complete_chain + description: Allows to find the root certificate for the returned fullchain. + - module: community.crypto.acme_certificate_revoke + description: Allows to revoke certificates. + - module: community.crypto.acme_account + description: Allows to create, modify or delete an ACME account. + - module: community.crypto.acme_inspect + description: Allows to debug problems. +extends_documentation_fragment: + - community.crypto.acme + - community.crypto.attributes + - community.crypto.attributes.files + - community.crypto.attributes.actiongroup_acme +attributes: + check_mode: + support: full + diff_mode: + support: none + safe_file_operations: + support: full +options: + account_email: + description: + - "The email address associated with this account." + - "It will be used for certificate expiration warnings." + - "Note that when C(modify_account) is not set to C(false) and you also + used the M(community.crypto.acme_account) module to specify more than one contact + for your account, this module will update your account and restrict + it to the (at most one) contact email address specified here." + type: str + agreement: + description: + - "URI to a terms of service document you agree to when using the + ACME v1 service at C(acme_directory)." + - Default is latest gathered from C(acme_directory) URL. + - This option will only be used when C(acme_version) is 1. + type: str + terms_agreed: + description: + - "Boolean indicating whether you agree to the terms of service document." + - "ACME servers can require this to be true." + - This option will only be used when C(acme_version) is not 1. + type: bool + default: false + modify_account: + description: + - "Boolean indicating whether the module should create the account if + necessary, and update its contact data." + - "Set to C(false) if you want to use the M(community.crypto.acme_account) module to manage + your account instead, and to avoid accidental creation of a new account + using an old key if you changed the account key with M(community.crypto.acme_account)." + - "If set to C(false), C(terms_agreed) and C(account_email) are ignored." + type: bool + default: true + challenge: + description: The challenge to be performed. + type: str + default: 'http-01' + choices: [ 'http-01', 'dns-01', 'tls-alpn-01' ] + 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 I(csr) or I(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 I(csr) or I(csr_content) must be specified. + type: str + version_added: 1.2.0 + data: + description: + - "The data to validate ongoing challenges. This must be specified for + the second run of the module only." + - "The value that must be used here will be provided by a previous use + of this module. See the examples for more details." + - "Note that for ACME v2, only the C(order_uri) entry of C(data) will + be used. For ACME v1, C(data) must be non-empty to indicate the + second stage is active; all needed data will be taken from the + CSR." + - "I(Note): the C(data) option was marked as C(no_log) up to + Ansible 2.5. From Ansible 2.6 on, it is no longer marked this way + as it causes error messages to be come unusable, and C(data) does + not contain any information which can be used without having + access to the account key or which are not public anyway." + type: dict + dest: + description: + - "The destination file for the certificate." + - "Required if C(fullchain_dest) is not specified." + type: path + aliases: ['cert'] + fullchain_dest: + description: + - "The destination file for the full chain (that is, a certificate followed + by chain of intermediate certificates)." + - "Required if C(dest) is not specified." + type: path + aliases: ['fullchain'] + chain_dest: + description: + - If specified, the intermediate certificate will be written to this file. + type: path + aliases: ['chain'] + remaining_days: + description: + - "The number of days the certificate must have left being valid. + If C(cert_days < remaining_days), then it will be renewed. + If the certificate is not renewed, module return values will not + include C(challenge_data)." + - "To make sure that the certificate is renewed in any case, you can + use the C(force) option." + type: int + default: 10 + deactivate_authzs: + description: + - "Deactivate authentication objects (authz) after issuing a certificate, + or when issuing the certificate failed." + - "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." + type: bool + default: false + force: + description: + - Enforces the execution of the challenge and validation, even if an + existing certificate is still valid for more than C(remaining_days). + - This is especially helpful when having an updated CSR, for example with + additional domains for which a new certificate is desired. + type: bool + default: false + retrieve_all_alternates: + description: + - "When set to C(true), will retrieve all alternate trust chains offered by the ACME CA. + These will not be written to disk, but will be returned together with the main + chain as C(all_chains). See the documentation for the C(all_chains) return + value for details." + type: bool + default: false + select_chain: + description: + - "Allows to specify criteria by which an (alternate) trust chain can be selected." + - "The list of criteria will be processed one by one until a chain is found + matching a criterium. If such a chain is found, it will be used by the + module instead of the default chain." + - "If a criterium matches multiple chains, the first one matching will be + returned. The order is determined by the ordering of the C(Link) headers + returned by the ACME server and might not be deterministic." + - "Every criterium can consist of multiple different conditions, like I(issuer) + and I(subject). For the criterium to match a chain, all conditions must apply + to the same certificate in the chain." + - "This option can only be used with the C(cryptography) backend." + type: list + elements: dict + version_added: '1.0.0' + suboptions: + test_certificates: + description: + - "Determines which certificates in the chain will be tested." + - "I(all) tests all certificates in the chain (excluding the leaf, which is + identical in all chains)." + - "I(first) only tests the first certificate in the chain, that is the one which + signed the leaf." + - "I(last) only tests the last certificate in the chain, that is the one furthest + away from the leaf. Its issuer is the root certificate of this chain." + type: str + default: all + choices: [first, last, all] + issuer: + description: + - "Allows to specify parts of the issuer of a certificate in the chain must + have to be selected." + - "If I(issuer) is empty, any certificate will match." + - 'An example value would be C({"commonName": "My Preferred CA Root"}).' + type: dict + subject: + description: + - "Allows to specify parts of the subject of a certificate in the chain must + have to be selected." + - "If I(subject) is empty, any certificate will match." + - 'An example value would be C({"CN": "My Preferred CA Intermediate"})' + type: dict + subject_key_identifier: + description: + - "Checks for the SubjectKeyIdentifier extension. This is an identifier based + on the private key of the intermediate certificate." + - "The identifier must be of the form + C(A8:4A:6A:63:04:7D:DD:BA:E6:D1:39:B7:A6:45:65:EF:F3:A8:EC:A1)." + type: str + authority_key_identifier: + description: + - "Checks for the AuthorityKeyIdentifier extension. This is an identifier based + on the private key of the issuer of the intermediate certificate." + - "The identifier must be of the form + C(C4:A7:B1:A4:7B:2C:71:FA:DB:E1:4B:90:75:FF:C4:15:60:85:89:10)." + type: str +''' + +EXAMPLES = r''' +### Example with HTTP challenge ### + +- name: Create a challenge for sample.com using a account key from a variable. + community.crypto.acme_certificate: + account_key_content: "{{ account_private_key }}" + csr: /etc/pki/cert/csr/sample.com.csr + dest: /etc/httpd/ssl/sample.com.crt + register: sample_com_challenge + +# Alternative first step: +- name: Create a challenge for sample.com using a account key from hashi vault. + community.crypto.acme_certificate: + account_key_content: "{{ lookup('hashi_vault', 'secret=secret/account_private_key:value') }}" + csr: /etc/pki/cert/csr/sample.com.csr + fullchain_dest: /etc/httpd/ssl/sample.com-fullchain.crt + register: sample_com_challenge + +# Alternative first step: +- name: Create a challenge for sample.com using a account key file. + community.crypto.acme_certificate: + account_key_src: /etc/pki/cert/private/account.key + csr_content: "{{ lookup('file', '/etc/pki/cert/csr/sample.com.csr') }}" + dest: /etc/httpd/ssl/sample.com.crt + fullchain_dest: /etc/httpd/ssl/sample.com-fullchain.crt + register: sample_com_challenge + +# perform the necessary steps to fulfill the challenge +# for example: +# +# - copy: +# dest: /var/www/html/{{ sample_com_challenge['challenge_data']['sample.com']['http-01']['resource'] }} +# content: "{{ sample_com_challenge['challenge_data']['sample.com']['http-01']['resource_value'] }}" +# when: sample_com_challenge is changed and 'sample.com' in sample_com_challenge['challenge_data'] +# +# Alternative way: +# +# - copy: +# dest: /var/www/{{ item.key }}/{{ item.value['http-01']['resource'] }} +# content: "{{ item.value['http-01']['resource_value'] }}" +# loop: "{{ sample_com_challenge.challenge_data | dict2items }}" +# when: sample_com_challenge is changed + +- name: Let the challenge be validated and retrieve the cert and intermediate certificate + community.crypto.acme_certificate: + account_key_src: /etc/pki/cert/private/account.key + csr: /etc/pki/cert/csr/sample.com.csr + dest: /etc/httpd/ssl/sample.com.crt + fullchain_dest: /etc/httpd/ssl/sample.com-fullchain.crt + chain_dest: /etc/httpd/ssl/sample.com-intermediate.crt + data: "{{ sample_com_challenge }}" + +### Example with DNS challenge against production ACME server ### + +- name: Create a challenge for sample.com using a account key file. + community.crypto.acme_certificate: + account_key_src: /etc/pki/cert/private/account.key + account_email: myself@sample.com + src: /etc/pki/cert/csr/sample.com.csr + cert: /etc/httpd/ssl/sample.com.crt + challenge: dns-01 + acme_directory: https://acme-v01.api.letsencrypt.org/directory + # Renew if the certificate is at least 30 days old + remaining_days: 60 + register: sample_com_challenge + +# perform the necessary steps to fulfill the challenge +# for example: +# +# - community.aws.route53: +# zone: sample.com +# record: "{{ sample_com_challenge.challenge_data['sample.com']['dns-01'].record }}" +# type: TXT +# ttl: 60 +# 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\"') }}" +# when: sample_com_challenge is changed and 'sample.com' in sample_com_challenge.challenge_data +# +# Alternative way: +# +# - community.aws.route53: +# zone: sample.com +# record: "{{ item.key }}" +# type: TXT +# ttl: 60 +# state: present +# 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 }}" +# loop: "{{ sample_com_challenge.challenge_data_dns | dict2items }}" +# when: sample_com_challenge is changed + +- name: Let the challenge be validated and retrieve the cert and intermediate certificate + community.crypto.acme_certificate: + account_key_src: /etc/pki/cert/private/account.key + account_email: myself@sample.com + src: /etc/pki/cert/csr/sample.com.csr + cert: /etc/httpd/ssl/sample.com.crt + fullchain: /etc/httpd/ssl/sample.com-fullchain.crt + chain: /etc/httpd/ssl/sample.com-intermediate.crt + challenge: dns-01 + acme_directory: https://acme-v01.api.letsencrypt.org/directory + remaining_days: 60 + data: "{{ sample_com_challenge }}" + when: sample_com_challenge is changed + +# Alternative second step: +- name: Let the challenge be validated and retrieve the cert and intermediate certificate + community.crypto.acme_certificate: + account_key_src: /etc/pki/cert/private/account.key + account_email: myself@sample.com + src: /etc/pki/cert/csr/sample.com.csr + cert: /etc/httpd/ssl/sample.com.crt + fullchain: /etc/httpd/ssl/sample.com-fullchain.crt + chain: /etc/httpd/ssl/sample.com-intermediate.crt + challenge: tls-alpn-01 + remaining_days: 60 + data: "{{ sample_com_challenge }}" + # We use Let's Encrypt's ACME v2 endpoint + acme_directory: https://acme-v02.api.letsencrypt.org/directory + acme_version: 2 + # The following makes sure that if a chain with /CN=DST Root CA X3 in its issuer is provided + # as an alternative, it will be selected. These are the roots cross-signed by IdenTrust. + # As long as Let's Encrypt provides alternate chains with the cross-signed root(s) when + # switching to their own ISRG Root X1 root, this will use the chain ending with a cross-signed + # root. This chain is more compatible with older TLS clients. + select_chain: + - test_certificates: last + issuer: + CN: DST Root CA X3 + O: Digital Signature Trust Co. + when: sample_com_challenge is changed +''' + +RETURN = ''' +cert_days: + description: The number of days the certificate remains valid. + returned: success + type: int +challenge_data: + description: + - 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 + 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 C(tls-alpn-01) + challenges. + returned: changed and challenge is C(tls-alpn-01) + type: str + sample: DNS:example.com + resource_value: + description: + - The value the resource has to produce for the validation. + - For C(http-01) and C(dns-01) challenges, the value can be used as-is. + - "For C(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 C(b64decode) 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 C(dns-01) + type: str + sample: _acme-challenge.example.com +challenge_data_dns: + description: + - List of TXT values per DNS record, in case challenge is C(dns-01). + - Since Ansible 2.8.5, only challenges which are not yet valid are returned. + returned: changed + type: dict +authorizations: + description: + - ACME authorization data. + - Maps an identifier to ACME authorization objects. See U(https://tools.ietf.org/html/rfc8555#section-7.1.4). + returned: changed + type: dict + sample: + example.com: + identifier: + type: dns + value: example.com + status: valid + expires: '2022-08-04T01:02:03.45Z' + challenges: + - url: https://example.org/acme/challenge/12345 + type: http-01 + status: valid + token: A5b1C3d2E9f8G7h6 + validated: '2022-08-01T01:01:02.34Z' + wildcard: false +order_uri: + description: ACME order URI. + returned: changed + type: str +finalization_uri: + description: ACME finalization URI. + returned: changed + type: str +account_uri: + description: ACME account URI. + returned: changed + type: str +all_chains: + description: + - When I(retrieve_all_alternates) is set to C(true), the module will query the ACME server + for alternate chains. This return value will contain a list of all chains returned, + the first entry being the main chain returned by the server. + - See L(Section 7.4.2 of RFC8555,https://tools.ietf.org/html/rfc8555#section-7.4.2) for details. + returned: when certificate was retrieved and I(retrieve_all_alternates) is set to C(true) + type: list + elements: dict + contains: + cert: + description: + - The leaf certificate itself, in PEM format. + type: str + returned: always + chain: + description: + - The certificate chain, excluding the root, as concatenated PEM certificates. + type: str + returned: always + full_chain: + description: + - The certificate chain, excluding the root, but including the leaf certificate, + as concatenated PEM certificates. + type: str + returned: always +''' + +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, + ACMEClient, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.account import ( + ACMEAccount, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.challenges import ( + combine_identifier, + split_identifier, + Authorization, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.certificates import ( + retrieve_acme_v1_certificate, + CertificateChain, + Criterium, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ( + ModuleFailException, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.io import ( + write_file, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.orders import ( + Order, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.utils import ( + pem_to_der, +) + + +class ACMECertificateClient(object): + ''' + ACME client class. Uses an ACME account object and a CSR to + start and validate ACME challenges and download the respective + certificates. + ''' + + def __init__(self, module, backend): + self.module = module + self.version = module.params['acme_version'] + self.challenge = module.params['challenge'] + self.csr = module.params['csr'] + self.csr_content = module.params['csr_content'] + self.dest = module.params.get('dest') + self.fullchain_dest = module.params.get('fullchain_dest') + self.chain_dest = module.params.get('chain_dest') + self.client = ACMEClient(module, backend) + self.account = ACMEAccount(self.client) + self.directory = self.client.directory + self.data = module.params['data'] + self.authorizations = None + self.cert_days = -1 + self.order = None + self.order_uri = self.data.get('order_uri') if self.data else None + self.all_chains = None + self.select_chain_matcher = [] + + if self.module.params['select_chain']: + for criterium_idx, criterium in enumerate(self.module.params['select_chain']): + try: + self.select_chain_matcher.append( + self.client.backend.create_chain_matcher( + Criterium(criterium, index=criterium_idx))) + except ValueError as exc: + self.module.warn('Error while parsing criterium: {error}. Ignoring criterium.'.format(error=exc)) + + # Make sure account exists + modify_account = module.params['modify_account'] + if modify_account or self.version > 1: + contact = [] + if module.params['account_email']: + contact.append('mailto:' + module.params['account_email']) + created, account_data = self.account.setup_account( + contact, + agreement=module.params.get('agreement'), + terms_agreed=module.params.get('terms_agreed'), + allow_creation=modify_account, + ) + if account_data is None: + raise ModuleFailException(msg='Account does not exist or is deactivated.') + updated = False + if not created and account_data and modify_account: + updated, account_data = self.account.update_account(account_data, contact) + self.changed = created or updated + else: + # This happens if modify_account is False and the ACME v1 + # protocol is used. In this case, we do not call setup_account() + # to avoid accidental creation of an account. This is OK + # since for ACME v1, the account URI is not needed to send a + # signed ACME request. + pass + + if self.csr is not None and not os.path.exists(self.csr): + raise ModuleFailException("CSR %s not found" % (self.csr)) + + # Extract list of identifiers from CSR + self.identifiers = self.client.backend.get_csr_identifiers(csr_filename=self.csr, csr_content=self.csr_content) + + def is_first_step(self): + ''' + Return True if this is the first execution of this module, i.e. if a + sufficient data object from a first run has not been provided. + ''' + if self.data is None: + return True + if self.version == 1: + # As soon as self.data is a non-empty object, we are in the second stage. + return not self.data + else: + # We are in the second stage if data.order_uri is given (which has been + # stored in self.order_uri by the constructor). + return self.order_uri is None + + def start_challenges(self): + ''' + Create new authorizations for all identifiers of the CSR, + respectively start a new order for ACME v2. + ''' + self.authorizations = {} + if self.version == 1: + for identifier_type, identifier in self.identifiers: + if identifier_type != 'dns': + raise ModuleFailException('ACME v1 only supports DNS identifiers!') + for identifier_type, identifier in self.identifiers: + authz = Authorization.create(self.client, identifier_type, identifier) + self.authorizations[authz.combined_identifier] = authz + else: + self.order = Order.create(self.client, self.identifiers) + self.order_uri = self.order.url + self.order.load_authorizations(self.client) + self.authorizations.update(self.order.authorizations) + self.changed = True + + def get_challenges_data(self, first_step): + ''' + Get challenge details for the chosen challenge type. + Return a tuple of generic challenge details, and specialized DNS challenge details. + ''' + # Get general challenge data + data = {} + for type_identifier, authz in self.authorizations.items(): + identifier_type, identifier = split_identifier(type_identifier) + # Skip valid authentications: their challenges are already valid + # and do not need to be returned + if authz.status == 'valid': + continue + # We drop the type from the key to preserve backwards compatibility + data[identifier] = authz.get_challenge_data(self.client) + if first_step and self.challenge not in data[identifier]: + raise ModuleFailException("Found no challenge of type '{0}' for identifier {1}!".format( + self.challenge, type_identifier)) + # Get DNS challenge data + data_dns = {} + if self.challenge == 'dns-01': + for identifier, challenges in data.items(): + if self.challenge in challenges: + values = data_dns.get(challenges[self.challenge]['record']) + if values is None: + values = [] + data_dns[challenges[self.challenge]['record']] = values + values.append(challenges[self.challenge]['resource_value']) + return data, data_dns + + def finish_challenges(self): + ''' + Verify challenges for all identifiers of the CSR. + ''' + self.authorizations = {} + + # Step 1: obtain challenge information + if self.version == 1: + # For ACME v1, we attempt to create new authzs. Existing ones + # will be returned instead. + for identifier_type, identifier in self.identifiers: + authz = Authorization.create(self.client, identifier_type, identifier) + self.authorizations[combine_identifier(identifier_type, identifier)] = authz + else: + # For ACME v2, we obtain the order object by fetching the + # order URI, and extract the information from there. + self.order = Order.from_url(self.client, self.order_uri) + self.order.load_authorizations(self.client) + self.authorizations.update(self.order.authorizations) + + # Step 2: validate pending challenges + for type_identifier, authz in self.authorizations.items(): + if authz.status == 'pending': + identifier_type, identifier = split_identifier(type_identifier) + authz.call_validate(self.client, self.challenge) + self.changed = True + + def download_alternate_chains(self, cert): + alternate_chains = [] + for alternate in cert.alternates: + try: + alt_cert = CertificateChain.download(self.client, alternate) + except ModuleFailException as e: + self.module.warn('Error while downloading alternative certificate {0}: {1}'.format(alternate, e)) + continue + alternate_chains.append(alt_cert) + return alternate_chains + + def find_matching_chain(self, chains): + for criterium_idx, matcher in enumerate(self.select_chain_matcher): + for chain in chains: + if matcher.match(chain): + self.module.debug('Found matching chain for criterium {0}'.format(criterium_idx)) + return chain + return None + + def get_certificate(self): + ''' + Request a new certificate and write it to the destination file. + First verifies whether all authorizations are valid; if not, aborts + with an error. + ''' + for identifier_type, identifier in self.identifiers: + authz = self.authorizations.get(combine_identifier(identifier_type, identifier)) + if authz is None: + raise ModuleFailException('Found no authorization information for "{identifier}"!'.format( + identifier=combine_identifier(identifier_type, identifier))) + if authz.status != 'valid': + authz.raise_error('Status is "{status}" and not "valid"'.format(status=authz.status), module=self.module) + + if self.version == 1: + cert = retrieve_acme_v1_certificate(self.client, pem_to_der(self.csr, self.csr_content)) + else: + self.order.finalize(self.client, pem_to_der(self.csr, self.csr_content)) + cert = CertificateChain.download(self.client, self.order.certificate_uri) + if self.module.params['retrieve_all_alternates'] or self.select_chain_matcher: + # Retrieve alternate chains + alternate_chains = self.download_alternate_chains(cert) + + # Prepare return value for all alternate chains + if self.module.params['retrieve_all_alternates']: + self.all_chains = [cert.to_json()] + for alt_chain in alternate_chains: + self.all_chains.append(alt_chain.to_json()) + + # Try to select alternate chain depending on criteria + if self.select_chain_matcher: + matching_chain = self.find_matching_chain([cert] + alternate_chains) + if matching_chain: + cert = matching_chain + else: + self.module.debug('Found no matching alternative chain') + + if cert.cert is not None: + pem_cert = cert.cert + chain = cert.chain + + if self.dest and write_file(self.module, self.dest, pem_cert.encode('utf8')): + self.cert_days = self.client.backend.get_cert_days(self.dest) + self.changed = True + + if self.fullchain_dest and write_file(self.module, self.fullchain_dest, (pem_cert + "\n".join(chain)).encode('utf8')): + self.cert_days = self.client.backend.get_cert_days(self.fullchain_dest) + self.changed = True + + if self.chain_dest and write_file(self.module, self.chain_dest, ("\n".join(chain)).encode('utf8')): + self.changed = True + + def deactivate_authzs(self): + ''' + Deactivates all valid authz's. Does not raise exceptions. + https://community.letsencrypt.org/t/authorization-deactivation/19860/2 + https://tools.ietf.org/html/rfc8555#section-7.5.2 + ''' + for authz in self.authorizations.values(): + try: + authz.deactivate(self.client) + except Exception: + # ignore errors + pass + if authz.status != 'deactivated': + self.module.warn(warning='Could not deactivate authz object {0}.'.format(authz.url)) + + +def main(): + argument_spec = get_default_argspec() + argument_spec.update(dict( + 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']), + 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']), + chain_dest=dict(type='path', aliases=['chain']), + remaining_days=dict(type='int', default=10), + deactivate_authzs=dict(type='bool', default=False), + force=dict(type='bool', default=False), + retrieve_all_alternates=dict(type='bool', default=False), + select_chain=dict(type='list', elements='dict', options=dict( + test_certificates=dict(type='str', default='all', choices=['first', 'last', 'all']), + issuer=dict(type='dict'), + subject=dict(type='dict'), + 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'], + ['dest', 'fullchain_dest'], + ['csr', 'csr_content'], + ), + mutually_exclusive=( + ['account_key_src', 'account_key_content'], + ['csr', 'csr_content'], + ), + supports_check_mode=True, + ) + backend = create_backend(module, False) + + try: + if module.params.get('dest'): + cert_days = backend.get_cert_days(module.params['dest']) + else: + cert_days = backend.get_cert_days(module.params['fullchain_dest']) + + if module.params['force'] or cert_days < module.params['remaining_days']: + # If checkmode is active, base the changed state solely on the status + # of the certificate file as all other actions (accessing an account, checking + # the authorization status...) would lead to potential changes of the current + # state + if module.check_mode: + module.exit_json(changed=True, authorizations={}, challenge_data={}, cert_days=cert_days) + else: + client = ACMECertificateClient(module, backend) + client.cert_days = cert_days + other = dict() + is_first_step = client.is_first_step() + if is_first_step: + # First run: start challenges / start new order + client.start_challenges() + else: + # Second run: finish challenges, and get certificate + try: + client.finish_challenges() + client.get_certificate() + if client.all_chains is not None: + other['all_chains'] = client.all_chains + finally: + if module.params['deactivate_authzs']: + client.deactivate_authzs() + data, data_dns = client.get_challenges_data(first_step=is_first_step) + auths = dict() + for k, v in client.authorizations.items(): + # Remove "type:" from key + auths[split_identifier(k)[1]] = v.to_json() + module.exit_json( + changed=client.changed, + authorizations=auths, + finalize_uri=client.order.finalize_uri if client.order else None, + order_uri=client.order_uri, + account_uri=client.client.account_uri, + challenge_data=data, + challenge_data_dns=data_dns, + cert_days=client.cert_days, + **other + ) + else: + module.exit_json(changed=False, cert_days=cert_days) + 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 new file mode 100644 index 00000000..f1922384 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/modules/acme_certificate_revoke.py @@ -0,0 +1,245 @@ +#!/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_revoke +author: "Felix Fontein (@felixfontein)" +short_description: Revoke certificates with the ACME protocol +description: + - "Allows to revoke certificates issued by a CA supporting the + L(ACME protocol,https://tools.ietf.org/html/rfc8555), + such as L(Let's Encrypt,https://letsencrypt.org/)." +notes: + - "Exactly one of C(account_key_src), C(account_key_content), + C(private_key_src) or C(private_key_content) must be specified." + - "Trying to revoke an already revoked certificate + should result in an unchanged status, even if the revocation reason + was different than the one specified here. Also, depending on the + server, it can happen that some other error is returned if the + certificate has already been revoked." +seealso: + - name: The Let's Encrypt documentation + description: Documentation for the Let's Encrypt Certification Authority. + Provides useful information for example on rate limits. + link: https://letsencrypt.org/docs/ + - name: Automatic Certificate Management Environment (ACME) + description: The specification of the ACME protocol (RFC 8555). + link: https://tools.ietf.org/html/rfc8555 + - module: community.crypto.acme_inspect + description: Allows to debug problems. +extends_documentation_fragment: + - community.crypto.acme + - community.crypto.attributes + - community.crypto.attributes.actiongroup_acme +attributes: + check_mode: + support: none + diff_mode: + support: none +options: + certificate: + description: + - "Path to the certificate to revoke." + type: path + required: true + account_key_src: + description: + - "Path to a file containing the ACME account RSA or Elliptic Curve + key." + - "RSA keys can be created with C(openssl rsa ...). 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 C(account_key_content)." + - "Required if C(account_key_content) is not used." + account_key_content: + description: + - "Content of the ACME account RSA or Elliptic Curve key." + - "Note that exactly one of C(account_key_src), C(account_key_content), + C(private_key_src) or C(private_key_content) must be specified." + - "I(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." + private_key_src: + description: + - "Path to the certificate's private key." + - "Note that exactly one of C(account_key_src), C(account_key_content), + C(private_key_src) or C(private_key_content) must be specified." + type: path + private_key_content: + description: + - "Content of the certificate's private key." + - "Note that exactly one of C(account_key_src), C(account_key_content), + C(private_key_src) or C(private_key_content) must be specified." + - "I(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 + private_key_passphrase: + description: + - Phassphrase to use to decode the certificate's private 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 + revoke_reason: + description: + - "One of the revocation reasonCodes defined in + L(Section 5.3.1 of RFC5280,https://tools.ietf.org/html/rfc5280#section-5.3.1)." + - "Possible values are C(0) (unspecified), C(1) (keyCompromise), + C(2) (cACompromise), C(3) (affiliationChanged), C(4) (superseded), + C(5) (cessationOfOperation), C(6) (certificateHold), + C(8) (removeFromCRL), C(9) (privilegeWithdrawn), + C(10) (aACompromise)." + type: int +''' + +EXAMPLES = ''' +- name: Revoke certificate with account key + community.crypto.acme_certificate_revoke: + account_key_src: /etc/pki/cert/private/account.key + certificate: /etc/httpd/ssl/sample.com.crt + +- name: Revoke certificate with certificate's private key + community.crypto.acme_certificate_revoke: + private_key_src: /etc/httpd/ssl/sample.com.key + certificate: /etc/httpd/ssl/sample.com.crt +''' + +RETURN = '''#''' + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.community.crypto.plugins.module_utils.acme.acme import ( + create_backend, + get_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 ( + ACMEProtocolException, + ModuleFailException, + KeyParsingError, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.utils import ( + nopad_b64, + pem_to_der, +) + + +def main(): + argument_spec = get_default_argspec() + argument_spec.update(dict( + 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, + 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, + ) + backend = create_backend(module, False) + + try: + client = ACMEClient(module, backend) + account = ACMEAccount(client) + # Load certificate + certificate = pem_to_der(module.params.get('certificate')) + certificate = nopad_b64(certificate) + # Construct payload + payload = { + 'certificate': certificate + } + if module.params.get('revoke_reason') is not None: + payload['reason'] = module.params.get('revoke_reason') + # Determine endpoint + if module.params.get('acme_version') == 1: + endpoint = client.directory['revoke-cert'] + payload['resource'] = 'revoke-cert' + else: + endpoint = client.directory['revokeCert'] + # Get hold of private key (if available) and make sure it comes from disk + private_key = module.params.get('private_key_src') + private_key_content = module.params.get('private_key_content') + # Revoke certificate + if private_key or private_key_content: + passphrase = module.params['private_key_passphrase'] + # Step 1: load and parse private key + try: + private_key_data = client.parse_key(private_key, private_key_content, passphrase=passphrase) + except KeyParsingError as e: + raise ModuleFailException("Error while parsing private key: {msg}".format(msg=e.msg)) + # Step 2: sign revokation request with private key + jws_header = { + "alg": private_key_data['alg'], + "jwk": private_key_data['jwk'], + } + result, info = client.send_signed_request( + endpoint, payload, key_data=private_key_data, jws_header=jws_header, fail_on_error=False) + else: + # Step 1: get hold of account URI + created, account_data = account.setup_account(allow_creation=False) + if created: + raise AssertionError('Unwanted account creation') + if account_data is None: + raise ModuleFailException(msg='Account does not exist or is deactivated.') + # Step 2: sign revokation request with account key + result, info = client.send_signed_request(endpoint, payload, fail_on_error=False) + if info['status'] != 200: + already_revoked = False + # Standardized error from draft 14 on (https://tools.ietf.org/html/rfc8555#section-7.6) + if result.get('type') == 'urn:ietf:params:acme:error:alreadyRevoked': + already_revoked = True + else: + # Hack for Boulder errors + if module.params.get('acme_version') == 1: + error_type = 'urn:acme:error:malformed' + else: + error_type = 'urn:ietf:params:acme:error:malformed' + if result.get('type') == error_type and result.get('detail') == 'Certificate already revoked': + # Fallback: boulder returns this in case the certificate was already revoked. + already_revoked = True + # If we know the certificate was already revoked, we do not fail, + # but successfully terminate while indicating no change + if already_revoked: + module.exit_json(changed=False) + raise ACMEProtocolException(module, 'Failed to revoke certificate', info=info, content_json=result) + module.exit_json(changed=True) + except ModuleFailException as e: + e.do_fail(module) + + +if __name__ == '__main__': + main() 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 new file mode 100644 index 00000000..1b963e8c --- /dev/null +++ b/ansible_collections/community/crypto/plugins/modules/acme_challenge_cert_helper.py @@ -0,0 +1,319 @@ +#!/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_challenge_cert_helper +author: "Felix Fontein (@felixfontein)" +short_description: Prepare certificates required for ACME challenges such as C(tls-alpn-01) +description: + - "Prepares certificates for ACME challenges such as C(tls-alpn-01)." + - "The raw data is provided by the M(community.crypto.acme_certificate) module, and needs to be + converted to a certificate to be used for challenge validation. This module + provides a simple way to generate the required certificates." +seealso: + - name: Automatic Certificate Management Environment (ACME) + description: The specification of the ACME protocol (RFC 8555). + link: https://tools.ietf.org/html/rfc8555 + - name: ACME TLS ALPN Challenge Extension + description: The specification of the C(tls-alpn-01) challenge (RFC 8737). + link: https://www.rfc-editor.org/rfc/rfc8737.html +requirements: + - "cryptography >= 1.3" +extends_documentation_fragment: + - community.crypto.attributes +attributes: + check_mode: + support: none + details: + - This action does not modify state. + diff_mode: + support: N/A + details: + - This action does not modify state. +options: + challenge: + description: + - "The challenge type." + type: str + required: true + choices: + - tls-alpn-01 + challenge_data: + description: + - "The C(challenge_data) entry provided by M(community.crypto.acme_certificate) for the + challenge." + type: dict + required: true + private_key_src: + description: + - "Path to a file containing the private key file to use for this challenge + certificate." + - "Mutually exclusive with C(private_key_content)." + type: path + private_key_content: + description: + - "Content of the private key to use for this challenge certificate." + - "Mutually exclusive with C(private_key_src)." + type: str + private_key_passphrase: + description: + - Phassphrase to use to decode the private key. + type: str + version_added: 1.6.0 +''' + +EXAMPLES = ''' +- name: Create challenges for a given CRT for sample.com + community.crypto.acme_certificate: + account_key_src: /etc/pki/cert/private/account.key + challenge: tls-alpn-01 + csr: /etc/pki/cert/csr/sample.com.csr + dest: /etc/httpd/ssl/sample.com.crt + register: sample_com_challenge + +- name: Create certificates for challenges + community.crypto.acme_challenge_cert_helper: + challenge: tls-alpn-01 + challenge_data: "{{ item.value['tls-alpn-01'] }}" + private_key_src: /etc/pki/cert/key/sample.com.key + loop: "{{ sample_com_challenge.challenge_data | dictsort }}" + register: sample_com_challenge_certs + +- name: Install challenge certificates + # We need to set up HTTPS such that for the domain, + # regular_certificate is delivered for regular connections, + # except if ALPN selects the "acme-tls/1"; then, the + # challenge_certificate must be delivered. + # This can for example be achieved with very new versions + # of NGINX; search for ssl_preread and + # ssl_preread_alpn_protocols for information on how to + # route by ALPN protocol. + ...: + domain: "{{ item.domain }}" + challenge_certificate: "{{ item.challenge_certificate }}" + regular_certificate: "{{ item.regular_certificate }}" + private_key: /etc/pki/cert/key/sample.com.key + loop: "{{ sample_com_challenge_certs.results }}" + +- name: Create certificate for a given CSR for sample.com + community.crypto.acme_certificate: + account_key_src: /etc/pki/cert/private/account.key + challenge: tls-alpn-01 + csr: /etc/pki/cert/csr/sample.com.csr + dest: /etc/httpd/ssl/sample.com.crt + data: "{{ sample_com_challenge }}" +''' + +RETURN = ''' +domain: + description: + - "The domain the challenge is for. The certificate should be provided if + this is specified in the request's the C(Host) header." + returned: always + type: str +identifier_type: + description: + - "The identifier type for the actual resource identifier. Will be C(dns) + or C(ip)." + returned: always + type: str +identifier: + description: + - "The identifier for the actual resource. Will be a domain name if the + type is C(dns), or an IP address if the type is C(ip)." + returned: always + type: str +challenge_certificate: + description: + - "The challenge certificate in PEM format." + returned: always + type: str +regular_certificate: + description: + - "A self-signed certificate for the challenge domain." + - "If no existing certificate exists, can be used to set-up + https in the first place if that is needed for providing + the challenge." + returned: always + type: str +''' + +import base64 +import datetime +import sys +import traceback + +from ansible.module_utils.basic import AnsibleModule, missing_required_lib +from ansible.module_utils.common.text.converters import to_bytes, to_text + +from ansible_collections.community.crypto.plugins.module_utils.version import LooseVersion + +from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ModuleFailException + +from ansible_collections.community.crypto.plugins.module_utils.acme.io import ( + read_file, +) + +CRYPTOGRAPHY_IMP_ERR = None +try: + import cryptography + import cryptography.hazmat.backends + import cryptography.hazmat.primitives.serialization + import cryptography.hazmat.primitives.asymmetric.rsa + import cryptography.hazmat.primitives.asymmetric.ec + import cryptography.hazmat.primitives.asymmetric.padding + import cryptography.hazmat.primitives.hashes + import cryptography.hazmat.primitives.asymmetric.utils + import cryptography.x509 + import cryptography.x509.oid + import ipaddress + HAS_CRYPTOGRAPHY = (LooseVersion(cryptography.__version__) >= LooseVersion('1.3')) + _cryptography_backend = cryptography.hazmat.backends.default_backend() +except ImportError as dummy: + CRYPTOGRAPHY_IMP_ERR = traceback.format_exc() + HAS_CRYPTOGRAPHY = False + + +# Convert byte string to ASN1 encoded octet string +if sys.version_info[0] >= 3: + def encode_octet_string(octet_string): + if len(octet_string) >= 128: + raise ModuleFailException('Cannot handle octet strings with more than 128 bytes') + return bytes([0x4, len(octet_string)]) + octet_string +else: + def encode_octet_string(octet_string): + if len(octet_string) >= 128: + raise ModuleFailException('Cannot handle octet strings with more than 128 bytes') + return b'\x04' + chr(len(octet_string)) + octet_string + + +def main(): + module = AnsibleModule( + argument_spec=dict( + challenge=dict(type='str', required=True, choices=['tls-alpn-01']), + challenge_data=dict(type='dict', required=True), + private_key_src=dict(type='path'), + private_key_content=dict(type='str', no_log=True), + private_key_passphrase=dict(type='str', no_log=True), + ), + required_one_of=( + ['private_key_src', 'private_key_content'], + ), + mutually_exclusive=( + ['private_key_src', 'private_key_content'], + ), + ) + if not HAS_CRYPTOGRAPHY: + # Some callbacks die when exception is provided with value None + if CRYPTOGRAPHY_IMP_ERR: + module.fail_json(msg=missing_required_lib('cryptography >= 1.3'), exception=CRYPTOGRAPHY_IMP_ERR) + module.fail_json(msg=missing_required_lib('cryptography >= 1.3')) + + try: + # Get parameters + challenge = module.params['challenge'] + challenge_data = module.params['challenge_data'] + + # Get hold of private key + private_key_content = module.params.get('private_key_content') + private_key_passphrase = module.params.get('private_key_passphrase') + if private_key_content is None: + private_key_content = read_file(module.params['private_key_src']) + else: + private_key_content = to_bytes(private_key_content) + try: + private_key = cryptography.hazmat.primitives.serialization.load_pem_private_key( + private_key_content, + password=to_bytes(private_key_passphrase) if private_key_passphrase is not None else None, + backend=_cryptography_backend) + except Exception as e: + raise ModuleFailException('Error while loading private key: {0}'.format(e)) + + # Some common attributes + domain = to_text(challenge_data['resource']) + identifier_type, identifier = to_text(challenge_data.get('resource_original', 'dns:' + challenge_data['resource'])).split(':', 1) + subject = issuer = cryptography.x509.Name([]) + not_valid_before = datetime.datetime.utcnow() + not_valid_after = datetime.datetime.utcnow() + datetime.timedelta(days=10) + if identifier_type == 'dns': + san = cryptography.x509.DNSName(identifier) + elif identifier_type == 'ip': + san = cryptography.x509.IPAddress(ipaddress.ip_address(identifier)) + else: + raise ModuleFailException('Unsupported identifier type "{0}"'.format(identifier_type)) + + # Generate regular self-signed certificate + regular_certificate = cryptography.x509.CertificateBuilder().subject_name( + subject + ).issuer_name( + issuer + ).public_key( + private_key.public_key() + ).serial_number( + cryptography.x509.random_serial_number() + ).not_valid_before( + not_valid_before + ).not_valid_after( + not_valid_after + ).add_extension( + cryptography.x509.SubjectAlternativeName([san]), + critical=False, + ).sign( + private_key, + cryptography.hazmat.primitives.hashes.SHA256(), + _cryptography_backend + ) + + # Process challenge + if challenge == 'tls-alpn-01': + value = base64.b64decode(challenge_data['resource_value']) + challenge_certificate = cryptography.x509.CertificateBuilder().subject_name( + subject + ).issuer_name( + issuer + ).public_key( + private_key.public_key() + ).serial_number( + cryptography.x509.random_serial_number() + ).not_valid_before( + not_valid_before + ).not_valid_after( + not_valid_after + ).add_extension( + cryptography.x509.SubjectAlternativeName([san]), + critical=False, + ).add_extension( + cryptography.x509.UnrecognizedExtension( + cryptography.x509.ObjectIdentifier("1.3.6.1.5.5.7.1.31"), + encode_octet_string(value), + ), + critical=True, + ).sign( + private_key, + cryptography.hazmat.primitives.hashes.SHA256(), + _cryptography_backend + ) + + module.exit_json( + changed=True, + domain=domain, + identifier_type=identifier_type, + identifier=identifier, + challenge_certificate=challenge_certificate.public_bytes(cryptography.hazmat.primitives.serialization.Encoding.PEM), + regular_certificate=regular_certificate.public_bytes(cryptography.hazmat.primitives.serialization.Encoding.PEM) + ) + except ModuleFailException as e: + e.do_fail(module) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/crypto/plugins/modules/acme_inspect.py b/ansible_collections/community/crypto/plugins/modules/acme_inspect.py new file mode 100644 index 00000000..d5c96b72 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/modules/acme_inspect.py @@ -0,0 +1,325 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2018 Felix Fontein (@felixfontein) +# 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 = r''' +--- +module: acme_inspect +author: "Felix Fontein (@felixfontein)" +short_description: Send direct requests to an ACME server +description: + - "Allows to send direct requests to an ACME server with the + L(ACME protocol,https://tools.ietf.org/html/rfc8555), + which is supported by CAs such as L(Let's Encrypt,https://letsencrypt.org/)." + - "This module can be used to debug failed certificate request attempts, + for example when M(community.crypto.acme_certificate) fails or encounters a problem which + you wish to investigate." + - "The module can also be used to directly access features of an ACME servers + which are not yet supported by the Ansible ACME modules." +notes: + - "The I(account_uri) option must be specified for properly authenticated + ACME v2 requests (except a C(new-account) request)." + - "Using the C(ansible) tool, M(community.crypto.acme_inspect) can be used to directly execute + ACME requests without the need of writing a playbook. For example, the + following command retrieves the ACME account with ID 1 from Let's Encrypt + (assuming C(/path/to/key) is the correct private account key): + C(ansible localhost -m acme_inspect -a \"account_key_src=/path/to/key + acme_directory=https://acme-v02.api.letsencrypt.org/directory acme_version=2 + account_uri=https://acme-v02.api.letsencrypt.org/acme/acct/1 method=get + url=https://acme-v02.api.letsencrypt.org/acme/acct/1\")" +seealso: + - name: Automatic Certificate Management Environment (ACME) + description: The specification of the ACME protocol (RFC 8555). + link: https://tools.ietf.org/html/rfc8555 + - name: ACME TLS ALPN Challenge Extension + 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.attributes + - community.crypto.attributes.actiongroup_acme +attributes: + check_mode: + support: none + diff_mode: + support: none +options: + url: + description: + - "The URL to send the request to." + - "Must be specified if I(method) is not C(directory-only)." + type: str + method: + description: + - "The method to use to access the given URL on the ACME server." + - "The value C(post) executes an authenticated POST request. The content + must be specified in the I(content) option." + - "The value C(get) executes an authenticated POST-as-GET request for ACME v2, + and a regular GET request for ACME v1." + - "The value C(directory-only) only retrieves the directory, without doing + a request." + type: str + default: get + choices: + - get + - post + - directory-only + content: + description: + - "An encoded JSON object which will be sent as the content if I(method) + is C(post)." + - "Required when I(method) is C(post), and not allowed otherwise." + type: str + fail_on_acme_error: + description: + - "If I(method) is C(post) or C(get), make the module fail in case an ACME + error is returned." + type: bool + default: true +''' + +EXAMPLES = r''' +- name: Get directory + community.crypto.acme_inspect: + acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory + acme_version: 2 + method: directory-only + register: directory + +- name: Create an account + community.crypto.acme_inspect: + acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory + acme_version: 2 + account_key_src: /etc/pki/cert/private/account.key + url: "{{ directory.newAccount}}" + method: post + content: '{"termsOfServiceAgreed":true}' + register: account_creation + # account_creation.headers.location contains the account URI + # if creation was successful + +- name: Get account information + community.crypto.acme_inspect: + acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory + acme_version: 2 + account_key_src: /etc/pki/cert/private/account.key + account_uri: "{{ account_creation.headers.location }}" + url: "{{ account_creation.headers.location }}" + method: get + +- name: Update account contacts + community.crypto.acme_inspect: + acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory + acme_version: 2 + account_key_src: /etc/pki/cert/private/account.key + account_uri: "{{ account_creation.headers.location }}" + url: "{{ account_creation.headers.location }}" + method: post + content: '{{ account_info | to_json }}' + vars: + account_info: + # For valid values, see + # https://tools.ietf.org/html/rfc8555#section-7.3 + contact: + - mailto:me@example.com + +- name: Create certificate order + community.crypto.acme_certificate: + acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory + acme_version: 2 + account_key_src: /etc/pki/cert/private/account.key + account_uri: "{{ account_creation.headers.location }}" + csr: /etc/pki/cert/csr/sample.com.csr + fullchain_dest: /etc/httpd/ssl/sample.com-fullchain.crt + challenge: http-01 + register: certificate_request + +# Assume something went wrong. certificate_request.order_uri contains +# the order URI. + +- name: Get order information + community.crypto.acme_inspect: + acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory + acme_version: 2 + account_key_src: /etc/pki/cert/private/account.key + account_uri: "{{ account_creation.headers.location }}" + url: "{{ certificate_request.order_uri }}" + method: get + register: order + +- name: Get first authz for order + community.crypto.acme_inspect: + acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory + acme_version: 2 + account_key_src: /etc/pki/cert/private/account.key + account_uri: "{{ account_creation.headers.location }}" + url: "{{ order.output_json.authorizations[0] }}" + method: get + register: authz + +- name: Get HTTP-01 challenge for authz + community.crypto.acme_inspect: + acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory + acme_version: 2 + account_key_src: /etc/pki/cert/private/account.key + account_uri: "{{ account_creation.headers.location }}" + url: "{{ authz.output_json.challenges | selectattr('type', 'equalto', 'http-01') }}" + method: get + register: http01challenge + +- name: Activate HTTP-01 challenge manually + community.crypto.acme_inspect: + acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory + acme_version: 2 + account_key_src: /etc/pki/cert/private/account.key + account_uri: "{{ account_creation.headers.location }}" + url: "{{ http01challenge.url }}" + method: post + content: '{}' +''' + +RETURN = ''' +directory: + description: The ACME directory's content + returned: always + type: dict + sample: + { + "a85k3x9f91A4": "https://community.letsencrypt.org/t/adding-random-entries-to-the-directory/33417", + "keyChange": "https://acme-v02.api.letsencrypt.org/acme/key-change", + "meta": { + "caaIdentities": [ + "letsencrypt.org" + ], + "termsOfService": "https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf", + "website": "https://letsencrypt.org" + }, + "newAccount": "https://acme-v02.api.letsencrypt.org/acme/new-acct", + "newNonce": "https://acme-v02.api.letsencrypt.org/acme/new-nonce", + "newOrder": "https://acme-v02.api.letsencrypt.org/acme/new-order", + "revokeCert": "https://acme-v02.api.letsencrypt.org/acme/revoke-cert" + } +headers: + description: The request's HTTP headers (with lowercase keys) + returned: always + type: dict + sample: + { + "boulder-requester": "12345", + "cache-control": "max-age=0, no-cache, no-store", + "connection": "close", + "content-length": "904", + "content-type": "application/json", + "cookies": {}, + "cookies_string": "", + "date": "Wed, 07 Nov 2018 12:34:56 GMT", + "expires": "Wed, 07 Nov 2018 12:44:56 GMT", + "link": '<https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf>;rel="terms-of-service"', + "msg": "OK (904 bytes)", + "pragma": "no-cache", + "replay-nonce": "1234567890abcdefghijklmnopqrstuvwxyzABCDEFGH", + "server": "nginx", + "status": 200, + "strict-transport-security": "max-age=604800", + "url": "https://acme-v02.api.letsencrypt.org/acme/acct/46161", + "x-frame-options": "DENY" + } +output_text: + description: The raw text output + returned: always + type: str + sample: "{\\n \\\"id\\\": 12345,\\n \\\"key\\\": {\\n \\\"kty\\\": \\\"RSA\\\",\\n ..." +output_json: + description: The output parsed as JSON + returned: if output can be parsed as JSON + type: dict + sample: + - id: 12345 + - key: + - kty: RSA + - ... +''' + +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, + ACMEClient, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ( + ACMEProtocolException, + ModuleFailException, +) + + +def main(): + argument_spec = get_default_argspec() + argument_spec.update(dict( + 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'], + ), + required_if=( + ['method', 'get', ['url']], + ['method', 'post', ['url', 'content']], + ['method', 'get', ['account_key_src', 'account_key_content'], True], + ['method', 'post', ['account_key_src', 'account_key_content'], True], + ), + ) + backend = create_backend(module, False) + + result = dict() + changed = False + try: + # Get hold of ACMEClient and ACMEAccount objects (includes directory) + client = ACMEClient(module, backend) + method = module.params['method'] + result['directory'] = client.directory.directory + # Do we have to do more requests? + if method != 'directory-only': + url = module.params['url'] + fail_on_acme_error = module.params['fail_on_acme_error'] + # Do request + if method == 'get': + data, info = client.get_request(url, parse_json_result=False, fail_on_error=False) + elif method == 'post': + changed = True # only POSTs can change + data, info = client.send_signed_request( + url, to_bytes(module.params['content']), parse_json_result=False, encode_payload=False, fail_on_error=False) + # Update results + result.update(dict( + headers=info, + output_text=to_native(data), + )) + # See if we can parse the result as JSON + try: + result['output_json'] = module.from_json(to_text(data)) + except Exception as dummy: + pass + # Fail if error was returned + if fail_on_acme_error and info['status'] >= 400: + raise ACMEProtocolException(module, info=info, content=data) + # Done! + module.exit_json(changed=changed, **result) + except ModuleFailException as e: + e.do_fail(module, **result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/crypto/plugins/modules/certificate_complete_chain.py b/ansible_collections/community/crypto/plugins/modules/certificate_complete_chain.py new file mode 100644 index 00000000..c05718e0 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/modules/certificate_complete_chain.py @@ -0,0 +1,375 @@ +#!/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: certificate_complete_chain +author: "Felix Fontein (@felixfontein)" +short_description: Complete certificate chain given a set of untrusted and root certificates +description: + - "This module completes a given chain of certificates in PEM format by finding + intermediate certificates from a given set of certificates, until it finds a root + certificate in another given set of certificates." + - "This can for example be used to find the root certificate for a certificate chain + returned by M(community.crypto.acme_certificate)." + - "Note that this module does I(not) check for validity of the chains. It only + checks that issuer and subject match, and that the signature is correct. It + ignores validity dates and key usage completely. If you need to verify that a + generated chain is valid, please use C(openssl verify ...)." +requirements: + - "cryptography >= 1.5" +extends_documentation_fragment: + - community.crypto.attributes +attributes: + check_mode: + support: full + details: + - This action does not modify state. + diff_mode: + support: N/A + details: + - This action does not modify state. +options: + input_chain: + description: + - A concatenated set of certificates in PEM format forming a chain. + - The module will try to complete this chain. + type: str + required: true + root_certificates: + description: + - "A list of filenames or directories." + - "A filename is assumed to point to a file containing one or more certificates + in PEM format. All certificates in this file will be added to the set of + root certificates." + - "If a directory name is given, all files in the directory and its + subdirectories will be scanned and tried to be parsed as concatenated + certificates in PEM format." + - "Symbolic links will be followed." + type: list + elements: path + required: true + intermediate_certificates: + description: + - "A list of filenames or directories." + - "A filename is assumed to point to a file containing one or more certificates + in PEM format. All certificates in this file will be added to the set of + root certificates." + - "If a directory name is given, all files in the directory and its + subdirectories will be scanned and tried to be parsed as concatenated + certificates in PEM format." + - "Symbolic links will be followed." + type: list + elements: path + default: [] +''' + + +EXAMPLES = ''' +# Given a leaf certificate for www.ansible.com and one or more intermediate +# certificates, finds the associated root certificate. +- name: Find root certificate + community.crypto.certificate_complete_chain: + input_chain: "{{ lookup('file', '/etc/ssl/csr/www.ansible.com-fullchain.pem') }}" + root_certificates: + - /etc/ca-certificates/ + register: www_ansible_com +- name: Write root certificate to disk + copy: + dest: /etc/ssl/csr/www.ansible.com-root.pem + content: "{{ www_ansible_com.root }}" + +# Given a leaf certificate for www.ansible.com, and a list of intermediate +# certificates, finds the associated root certificate. +- name: Find root certificate + community.crypto.certificate_complete_chain: + input_chain: "{{ lookup('file', '/etc/ssl/csr/www.ansible.com.pem') }}" + intermediate_certificates: + - /etc/ssl/csr/www.ansible.com-chain.pem + root_certificates: + - /etc/ca-certificates/ + register: www_ansible_com +- name: Write complete chain to disk + copy: + dest: /etc/ssl/csr/www.ansible.com-completechain.pem + content: "{{ ''.join(www_ansible_com.complete_chain) }}" +- name: Write root chain (intermediates and root) to disk + copy: + dest: /etc/ssl/csr/www.ansible.com-rootchain.pem + content: "{{ ''.join(www_ansible_com.chain) }}" +''' + + +RETURN = ''' +root: + description: + - "The root certificate in PEM format." + returned: success + type: str +chain: + description: + - "The chain added to the given input chain. Includes the root certificate." + - "Returned as a list of PEM certificates." + returned: success + type: list + elements: str +complete_chain: + description: + - "The completed chain, including leaf, all intermediates, and root." + - "Returned as a list of PEM certificates." + returned: success + type: list + elements: str +''' + +import os +import traceback + +from ansible.module_utils.basic import AnsibleModule, 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.crypto.pem import ( + split_pem_list, +) + +CRYPTOGRAPHY_IMP_ERR = None +try: + import cryptography + import cryptography.exceptions + import cryptography.hazmat.backends + import cryptography.hazmat.primitives.serialization + import cryptography.hazmat.primitives.asymmetric.rsa + import cryptography.hazmat.primitives.asymmetric.ec + import cryptography.hazmat.primitives.asymmetric.padding + import cryptography.hazmat.primitives.hashes + import cryptography.hazmat.primitives.asymmetric.utils + import cryptography.x509 + import cryptography.x509.oid + HAS_CRYPTOGRAPHY = (LooseVersion(cryptography.__version__) >= LooseVersion('1.5')) + _cryptography_backend = cryptography.hazmat.backends.default_backend() +except ImportError as dummy: + CRYPTOGRAPHY_IMP_ERR = traceback.format_exc() + HAS_CRYPTOGRAPHY = False + + +class Certificate(object): + ''' + Stores PEM with parsed certificate. + ''' + def __init__(self, pem, cert): + if not (pem.endswith('\n') or pem.endswith('\r')): + pem = pem + '\n' + self.pem = pem + self.cert = cert + + +def is_parent(module, cert, potential_parent): + ''' + Tests whether the given certificate has been issued by the potential parent certificate. + ''' + # Check issuer + if cert.cert.issuer != potential_parent.cert.subject: + return False + # Check signature + public_key = potential_parent.cert.public_key() + try: + if isinstance(public_key, cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey): + public_key.verify( + cert.cert.signature, + cert.cert.tbs_certificate_bytes, + cryptography.hazmat.primitives.asymmetric.padding.PKCS1v15(), + cert.cert.signature_hash_algorithm + ) + elif isinstance(public_key, cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePublicKey): + public_key.verify( + cert.cert.signature, + cert.cert.tbs_certificate_bytes, + cryptography.hazmat.primitives.asymmetric.ec.ECDSA(cert.cert.signature_hash_algorithm), + ) + else: + # Unknown public key type + module.warn('Unknown public key type "{0}"'.format(public_key)) + return False + return True + except cryptography.exceptions.InvalidSignature as dummy: + return False + except cryptography.exceptions.UnsupportedAlgorithm as dummy: + module.warn('Unsupported algorithm "{0}"'.format(cert.cert.signature_hash_algorithm)) + return False + except Exception as e: + module.fail_json(msg='Unknown error on signature validation: {0}'.format(e)) + + +def parse_PEM_list(module, text, source, fail_on_error=True): + ''' + Parse concatenated PEM certificates. Return list of ``Certificate`` objects. + ''' + result = [] + for cert_pem in split_pem_list(text): + # Try to load PEM certificate + try: + cert = cryptography.x509.load_pem_x509_certificate(to_bytes(cert_pem), _cryptography_backend) + result.append(Certificate(cert_pem, cert)) + except Exception as e: + msg = 'Cannot parse certificate #{0} from {1}: {2}'.format(len(result) + 1, source, e) + if fail_on_error: + module.fail_json(msg=msg) + else: + module.warn(msg) + return result + + +def load_PEM_list(module, path, fail_on_error=True): + ''' + Load concatenated PEM certificates from file. Return list of ``Certificate`` objects. + ''' + try: + with open(path, "rb") as f: + return parse_PEM_list(module, f.read().decode('utf-8'), source=path, fail_on_error=fail_on_error) + except Exception as e: + msg = 'Cannot read certificate file {0}: {1}'.format(path, e) + if fail_on_error: + module.fail_json(msg=msg) + else: + module.warn(msg) + return [] + + +class CertificateSet(object): + ''' + Stores a set of certificates. Allows to search for parent (issuer of a certificate). + ''' + + def __init__(self, module): + self.module = module + self.certificates = set() + self.certificates_by_issuer = dict() + self.certificate_by_cert = dict() + + def _load_file(self, path): + certs = load_PEM_list(self.module, path, fail_on_error=False) + for cert in certs: + self.certificates.add(cert) + if cert.cert.subject not in self.certificates_by_issuer: + self.certificates_by_issuer[cert.cert.subject] = [] + self.certificates_by_issuer[cert.cert.subject].append(cert) + self.certificate_by_cert[cert.cert] = cert + + def load(self, path): + ''' + Load lists of PEM certificates from a file or a directory. + ''' + b_path = to_bytes(path, errors='surrogate_or_strict') + if os.path.isdir(b_path): + for directory, dummy, files in os.walk(b_path, followlinks=True): + for file in files: + self._load_file(os.path.join(directory, file)) + else: + self._load_file(b_path) + + def find_parent(self, cert): + ''' + Search for the parent (issuer) of a certificate. Return ``None`` if none was found. + ''' + potential_parents = self.certificates_by_issuer.get(cert.cert.issuer, []) + for potential_parent in potential_parents: + if is_parent(self.module, cert, potential_parent): + return potential_parent + return None + + +def format_cert(cert): + ''' + Return human readable representation of certificate for error messages. + ''' + return str(cert.cert) + + +def check_cycle(module, occured_certificates, next): + ''' + Make sure that next is not in occured_certificates so far, and add it. + ''' + next_cert = next.cert + if next_cert in occured_certificates: + module.fail_json(msg='Found cycle while building certificate chain') + occured_certificates.add(next_cert) + + +def main(): + module = AnsibleModule( + argument_spec=dict( + input_chain=dict(type='str', required=True), + root_certificates=dict(type='list', required=True, elements='path'), + intermediate_certificates=dict(type='list', default=[], elements='path'), + ), + supports_check_mode=True, + ) + + if not HAS_CRYPTOGRAPHY: + module.fail_json(msg=missing_required_lib('cryptography >= 1.5'), exception=CRYPTOGRAPHY_IMP_ERR) + + # Load chain + chain = parse_PEM_list(module, module.params['input_chain'], source='input chain') + if len(chain) == 0: + module.fail_json(msg='Input chain must contain at least one certificate') + + # Check chain + for i, parent in enumerate(chain): + if i > 0: + if not is_parent(module, chain[i - 1], parent): + module.fail_json(msg=('Cannot verify input chain: certificate #{2}: {3} is not issuer ' + + 'of certificate #{0}: {1}').format(i, format_cert(chain[i - 1]), i + 1, format_cert(parent))) + + # Load intermediate certificates + intermediates = CertificateSet(module) + for path in module.params['intermediate_certificates']: + intermediates.load(path) + + # Load root certificates + roots = CertificateSet(module) + for path in module.params['root_certificates']: + roots.load(path) + + # Try to complete chain + current = chain[-1] + completed = [] + occured_certificates = set([cert.cert for cert in chain]) + if current.cert in roots.certificate_by_cert: + # Do not try to complete the chain when it's already ending with a root certificate + current = None + while current: + root = roots.find_parent(current) + if root: + check_cycle(module, occured_certificates, root) + completed.append(root) + break + intermediate = intermediates.find_parent(current) + if intermediate: + check_cycle(module, occured_certificates, intermediate) + completed.append(intermediate) + current = intermediate + else: + module.fail_json(msg='Cannot complete chain. Stuck at certificate {0}'.format(format_cert(current))) + + # Return results + complete_chain = chain + completed + module.exit_json( + changed=False, + root=complete_chain[-1].pem, + chain=[cert.pem for cert in completed], + complete_chain=[cert.pem for cert in complete_chain], + ) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/crypto/plugins/modules/crypto_info.py b/ansible_collections/community/crypto/plugins/modules/crypto_info.py new file mode 100644 index 00000000..1988eb32 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/modules/crypto_info.py @@ -0,0 +1,337 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2021 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 = r''' +--- +module: crypto_info +author: "Felix Fontein (@felixfontein)" +short_description: Retrieve cryptographic capabilities +version_added: 2.1.0 +description: + - Retrieve information on cryptographic capabilities. + - The current version retrieves information on the L(Python cryptography library, https://cryptography.io/) available to + Ansible modules, and on the OpenSSL binary C(openssl) found in the path. +extends_documentation_fragment: + - community.crypto.attributes + - community.crypto.attributes.info_module +options: {} +''' + +EXAMPLES = r''' +- name: Retrieve information + community.crypto.crypto_info: + account_key_src: /etc/pki/cert/private/account.key + register: crypto_information + +- name: Show retrieved information + ansible.builtin.debug: + var: crypto_information +''' + +RETURN = r''' +python_cryptography_installed: + description: Whether the L(Python cryptography library, https://cryptography.io/) is installed. + returned: always + type: bool + sample: true + +python_cryptography_import_error: + description: Import error when trying to import the L(Python cryptography library, https://cryptography.io/). + returned: when I(python_cryptography_installed=false) + type: str + +python_cryptography_capabilities: + description: Information on the installed L(Python cryptography library, https://cryptography.io/). + returned: when I(python_cryptography_installed=true) + type: dict + contains: + version: + description: The library version. + type: str + curves: + description: + - List of all supported elliptic curves. + - Theoretically this should be non-empty for version 0.5 and higher, depending on the libssl version used. + type: list + elements: str + has_ec: + description: + - Whether elliptic curves are supported. + - Theoretically this should be the case for version 0.5 and higher, depending on the libssl version used. + type: bool + has_ec_sign: + description: + - Whether signing with elliptic curves is supported. + - Theoretically this should be the case for version 1.5 and higher, depending on the libssl version used. + type: bool + has_ed25519: + description: + - Whether Ed25519 keys are supported. + - Theoretically this should be the case for version 2.6 and higher, depending on the libssl version used. + type: bool + has_ed25519_sign: + description: + - Whether signing with Ed25519 keys is supported. + - Theoretically this should be the case for version 2.6 and higher, depending on the libssl version used. + type: bool + has_ed448: + description: + - Whether Ed448 keys are supported. + - Theoretically this should be the case for version 2.6 and higher, depending on the libssl version used. + type: bool + has_ed448_sign: + description: + - Whether signing with Ed448 keys is supported. + - Theoretically this should be the case for version 2.6 and higher, depending on the libssl version used. + type: bool + has_dsa: + description: + - Whether DSA keys are supported. + - Theoretically this should be the case for version 0.5 and higher. + type: bool + has_dsa_sign: + description: + - Whether signing with DSA keys is supported. + - Theoretically this should be the case for version 1.5 and higher. + type: bool + has_rsa: + description: + - Whether RSA keys are supported. + - Theoretically this should be the case for version 0.5 and higher. + type: bool + has_rsa_sign: + description: + - Whether signing with RSA keys is supported. + - Theoretically this should be the case for version 1.4 and higher. + type: bool + has_x25519: + description: + - Whether X25519 keys are supported. + - Theoretically this should be the case for version 2.0 and higher, depending on the libssl version used. + type: bool + has_x25519_serialization: + description: + - Whether serialization of X25519 keys is supported. + - Theoretically this should be the case for version 2.5 and higher, depending on the libssl version used. + type: bool + has_x448: + description: + - Whether X448 keys are supported. + - Theoretically this should be the case for version 2.5 and higher, depending on the libssl version used. + type: bool + +openssl_present: + description: Whether the OpenSSL binary C(openssl) is installed and can be found in the PATH. + returned: always + type: bool + sample: true + +openssl: + description: Information on the installed OpenSSL binary. + returned: when I(openssl_present=true) + type: dict + contains: + path: + description: Path of the OpenSSL binary. + type: str + sample: /usr/bin/openssl + version: + description: The OpenSSL version. + type: str + sample: 1.1.1m + version_output: + description: The complete output of C(openssl version). + type: str + sample: 'OpenSSL 1.1.1m 14 Dec 2021\n' +''' + +import traceback + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import ( + CRYPTOGRAPHY_HAS_EC, + CRYPTOGRAPHY_HAS_EC_SIGN, + CRYPTOGRAPHY_HAS_ED25519, + CRYPTOGRAPHY_HAS_ED25519_SIGN, + CRYPTOGRAPHY_HAS_ED448, + CRYPTOGRAPHY_HAS_ED448_SIGN, + CRYPTOGRAPHY_HAS_DSA, + CRYPTOGRAPHY_HAS_DSA_SIGN, + CRYPTOGRAPHY_HAS_RSA, + CRYPTOGRAPHY_HAS_RSA_SIGN, + CRYPTOGRAPHY_HAS_X25519, + CRYPTOGRAPHY_HAS_X25519_FULL, + CRYPTOGRAPHY_HAS_X448, + HAS_CRYPTOGRAPHY, +) + +try: + import cryptography + from cryptography.exceptions import UnsupportedAlgorithm +except ImportError: + UnsupportedAlgorithm = Exception + CRYPTOGRAPHY_VERSION = None + CRYPTOGRAPHY_IMP_ERR = traceback.format_exc() +else: + CRYPTOGRAPHY_VERSION = cryptography.__version__ + CRYPTOGRAPHY_IMP_ERR = None + + +CURVES = ( + ('secp224r1', 'SECP224R1'), + ('secp256k1', 'SECP256K1'), + ('secp256r1', 'SECP256R1'), + ('secp384r1', 'SECP384R1'), + ('secp521r1', 'SECP521R1'), + ('secp192r1', 'SECP192R1'), + ('sect163k1', 'SECT163K1'), + ('sect163r2', 'SECT163R2'), + ('sect233k1', 'SECT233K1'), + ('sect233r1', 'SECT233R1'), + ('sect283k1', 'SECT283K1'), + ('sect283r1', 'SECT283R1'), + ('sect409k1', 'SECT409K1'), + ('sect409r1', 'SECT409R1'), + ('sect571k1', 'SECT571K1'), + ('sect571r1', 'SECT571R1'), + ('brainpoolP256r1', 'BrainpoolP256R1'), + ('brainpoolP384r1', 'BrainpoolP384R1'), + ('brainpoolP512r1', 'BrainpoolP512R1'), +) + + +def add_crypto_information(module): + result = {} + result['python_cryptography_installed'] = HAS_CRYPTOGRAPHY + if not HAS_CRYPTOGRAPHY: + result['python_cryptography_import_error'] = CRYPTOGRAPHY_IMP_ERR + return result + + has_ed25519 = CRYPTOGRAPHY_HAS_ED25519 + if has_ed25519: + try: + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + Ed25519PrivateKey.from_private_bytes(b'') + except ValueError: + pass + except UnsupportedAlgorithm: + has_ed25519 = False + + has_ed448 = CRYPTOGRAPHY_HAS_ED448 + if has_ed448: + try: + from cryptography.hazmat.primitives.asymmetric.ed448 import Ed448PrivateKey + Ed448PrivateKey.from_private_bytes(b'') + except ValueError: + pass + except UnsupportedAlgorithm: + has_ed448 = False + + has_x25519 = CRYPTOGRAPHY_HAS_X25519 + if has_x25519: + try: + from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey + if CRYPTOGRAPHY_HAS_X25519_FULL: + X25519PrivateKey.from_private_bytes(b'') + else: + # Some versions do not support serialization and deserialization - use generate() instead + X25519PrivateKey.generate() + except ValueError: + pass + except UnsupportedAlgorithm: + has_x25519 = False + + has_x448 = CRYPTOGRAPHY_HAS_X448 + if has_x448: + try: + from cryptography.hazmat.primitives.asymmetric.x448 import X448PrivateKey + X448PrivateKey.from_private_bytes(b'') + except ValueError: + pass + except UnsupportedAlgorithm: + has_x448 = False + + curves = [] + if CRYPTOGRAPHY_HAS_EC: + import cryptography.hazmat.backends + import cryptography.hazmat.primitives.asymmetric.ec + + backend = cryptography.hazmat.backends.default_backend() + for curve_name, constructor_name in CURVES: + ecclass = cryptography.hazmat.primitives.asymmetric.ec.__dict__.get(constructor_name) + if ecclass: + try: + cryptography.hazmat.primitives.asymmetric.ec.generate_private_key(curve=ecclass(), backend=backend) + curves.append(curve_name) + except UnsupportedAlgorithm: + pass + + info = { + 'version': CRYPTOGRAPHY_VERSION, + 'curves': curves, + 'has_ec': CRYPTOGRAPHY_HAS_EC, + 'has_ec_sign': CRYPTOGRAPHY_HAS_EC_SIGN, + 'has_ed25519': has_ed25519, + 'has_ed25519_sign': has_ed25519 and CRYPTOGRAPHY_HAS_ED25519_SIGN, + 'has_ed448': has_ed448, + 'has_ed448_sign': has_ed448 and CRYPTOGRAPHY_HAS_ED448_SIGN, + 'has_dsa': CRYPTOGRAPHY_HAS_DSA, + 'has_dsa_sign': CRYPTOGRAPHY_HAS_DSA_SIGN, + 'has_rsa': CRYPTOGRAPHY_HAS_RSA, + 'has_rsa_sign': CRYPTOGRAPHY_HAS_RSA_SIGN, + 'has_x25519': has_x25519, + 'has_x25519_serialization': has_x25519 and CRYPTOGRAPHY_HAS_X25519_FULL, + 'has_x448': has_x448, + } + result['python_cryptography_capabilities'] = info + return result + + +def add_openssl_information(module): + openssl_binary = module.get_bin_path('openssl') + result = { + 'openssl_present': openssl_binary is not None, + } + if openssl_binary is None: + return result + + openssl_result = { + 'path': openssl_binary, + } + result['openssl'] = openssl_result + + rc, out, err = module.run_command([openssl_binary, 'version']) + if rc == 0: + openssl_result['version_output'] = out + parts = out.split(None, 2) + if len(parts) > 1: + openssl_result['version'] = parts[1] + + return result + + +INFO_FUNCTIONS = ( + add_crypto_information, + add_openssl_information, +) + + +def main(): + module = AnsibleModule(argument_spec={}, supports_check_mode=True) + result = {} + for fn in INFO_FUNCTIONS: + result.update(fn(module)) + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/crypto/plugins/modules/ecs_certificate.py b/ansible_collections/community/crypto/plugins/modules/ecs_certificate.py new file mode 100644 index 00000000..b19b86f5 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/modules/ecs_certificate.py @@ -0,0 +1,966 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c), Entrust Datacard Corporation, 2019 +# 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: ecs_certificate +author: + - Chris Trufan (@ctrufan) +short_description: Request SSL/TLS certificates with the Entrust Certificate Services (ECS) API +description: + - Create, reissue, and renew certificates with the Entrust Certificate Services (ECS) API. + - Requires credentials for the L(Entrust Certificate Services,https://www.entrustdatacard.com/products/categories/ssl-certificates) (ECS) API. + - In order to request a certificate, the domain and organization used in the certificate signing request must be already + validated in the ECS system. It is I(not) the responsibility of this module to perform those steps. +notes: + - C(path) must be specified as the output location of the certificate. +requirements: + - cryptography >= 1.6 +extends_documentation_fragment: + - community.crypto.attributes + - community.crypto.attributes.files + - community.crypto.ecs_credential +attributes: + check_mode: + support: partial + details: + - Check mode is only supported if I(request_type=new). + diff_mode: + support: none + safe_file_operations: + support: full +options: + backup: + description: + - Whether a backup should be made for the certificate in I(path). + type: bool + default: false + force: + description: + - If force is used, a certificate is requested regardless of whether I(path) points to an existing valid certificate. + - If C(request_type=renew), a forced renew will fail if the certificate being renewed has been issued within the past 30 days, regardless of the + value of I(remaining_days) or the return value of I(cert_days) - the ECS API does not support the "renew" operation for certificates that are not + at least 30 days old. + type: bool + default: false + path: + description: + - The destination path for the generated certificate as a PEM encoded cert. + - If the certificate at this location is not an Entrust issued certificate, a new certificate will always be requested even if the current + certificate is technically valid. + - If there is already an Entrust certificate at this location, whether it is replaced is depends on the I(remaining_days) calculation. + - If an existing certificate is being replaced (see I(remaining_days), I(force), and I(tracking_id)), whether a new certificate is requested + or the existing certificate is renewed or reissued is based on I(request_type). + type: path + required: true + full_chain_path: + description: + - The destination path for the full certificate chain of the certificate, intermediates, and roots. + type: path + csr: + description: + - Base-64 encoded Certificate Signing Request (CSR). I(csr) is accepted with or without PEM formatting around the Base-64 string. + - If no I(csr) is provided when C(request_type=reissue) or C(request_type=renew), the certificate will be generated with the same public key as + the certificate being renewed or reissued. + - If I(subject_alt_name) is specified, it will override the subject alternate names in the CSR. + - If I(eku) is specified, it will override the extended key usage in the CSR. + - If I(ou) is specified, it will override the organizational units "ou=" present in the subject distinguished name of the CSR, if any. + - The organization "O=" field from the CSR will not be used. It will be replaced in the issued certificate by I(org) if present, and if not present, + the organization tied to I(client_id). + type: str + tracking_id: + description: + - The tracking ID of the certificate to reissue or renew. + - I(tracking_id) is invalid if C(request_type=new) or C(request_type=validate_only). + - If there is a certificate present in I(path) and it is an ECS certificate, I(tracking_id) will be ignored. + - If there is no certificate present in I(path) or there is but it is from another provider, the certificate represented by I(tracking_id) will + be renewed or reissued and saved to I(path). + - If there is no certificate present in I(path) and the I(force) and I(remaining_days) parameters do not indicate a new certificate is needed, + the certificate referenced by I(tracking_id) certificate will be saved to I(path). + - This can be used when a known certificate is not currently present on a server, but you want to renew or reissue it to be managed by an ansible + playbook. For example, if you specify C(request_type=renew), I(tracking_id) of an issued certificate, and I(path) to a file that does not exist, + the first run of a task will download the certificate specified by I(tracking_id) (assuming it is still valid). Future runs of the task will + (if applicable - see I(force) and I(remaining_days)) renew the certificate now present in I(path). + type: int + remaining_days: + description: + - The number of days the certificate must have left being valid. If C(cert_days < remaining_days) then a new certificate will be + obtained using I(request_type). + - If C(request_type=renew), a renewal will fail if the certificate being renewed has been issued within the past 30 days, so do not set a + I(remaining_days) value that is within 30 days of the full lifetime of the certificate being acted upon. + - For exmaple, if you are requesting Certificates with a 90 day lifetime, do not set I(remaining_days) to a value C(60) or higher). + - The I(force) option may be used to ensure that a new certificate is always obtained. + type: int + default: 30 + request_type: + description: + - The operation performed if I(tracking_id) references a valid certificate to reissue, or there is already a certificate present in I(path) but + either I(force) is specified or C(cert_days < remaining_days). + - Specifying C(request_type=validate_only) means the request will be validated against the ECS API, but no certificate will be issued. + - Specifying C(request_type=new) means a certificate request will always be submitted and a new certificate issued. + - Specifying C(request_type=renew) means that an existing certificate (specified by I(tracking_id) if present, otherwise I(path)) will be renewed. + If there is no certificate to renew, a new certificate is requested. + - Specifying C(request_type=reissue) means that an existing certificate (specified by I(tracking_id) if present, otherwise I(path)) will be + reissued. + If there is no certificate to reissue, a new certificate is requested. + - If a certificate was issued within the past 30 days, the C(renew) operation is not a valid operation and will fail. + - Note that C(reissue) is an operation that will result in the revocation of the certificate that is reissued, be cautious with its use. + - I(check_mode) is only supported if C(request_type=new) + - For example, setting C(request_type=renew) and C(remaining_days=30) and pointing to the same certificate on multiple playbook runs means that on + the first run new certificate will be requested. It will then be left along on future runs until it is within 30 days of expiry, then the + ECS "renew" operation will be performed. + type: str + choices: [ 'new', 'renew', 'reissue', 'validate_only'] + default: new + cert_type: + description: + - Specify the type of certificate requested. + - If a certificate is being reissued or renewed, this parameter is ignored, and the C(cert_type) of the initial certificate is used. + type: str + choices: [ 'STANDARD_SSL', 'ADVANTAGE_SSL', 'UC_SSL', 'EV_SSL', 'WILDCARD_SSL', 'PRIVATE_SSL', 'PD_SSL', 'CODE_SIGNING', 'EV_CODE_SIGNING', + 'CDS_INDIVIDUAL', 'CDS_GROUP', 'CDS_ENT_LITE', 'CDS_ENT_PRO', 'SMIME_ENT' ] + subject_alt_name: + description: + - The subject alternative name identifiers, as an array of values (applies to I(cert_type) with a value of C(STANDARD_SSL), C(ADVANTAGE_SSL), + C(UC_SSL), C(EV_SSL), C(WILDCARD_SSL), C(PRIVATE_SSL), and C(PD_SSL)). + - If you are requesting a new SSL certificate, and you pass a I(subject_alt_name) parameter, any SAN names in the CSR are ignored. + If no subjectAltName parameter is passed, the SAN names in the CSR are used. + - See I(request_type) to understand more about SANs during reissues and renewals. + - In the case of certificates of type C(STANDARD_SSL) certificates, if the CN of the certificate is <domain>.<tld> only the www.<domain>.<tld> value + is accepted. If the CN of the certificate is www.<domain>.<tld> only the <domain>.<tld> value is accepted. + type: list + elements: str + eku: + description: + - If specified, overrides the key usage in the I(csr). + type: str + choices: [ SERVER_AUTH, CLIENT_AUTH, SERVER_AND_CLIENT_AUTH ] + ct_log: + description: + - In compliance with browser requirements, this certificate may be posted to the Certificate Transparency (CT) logs. This is a best practice + technique that helps domain owners monitor certificates issued to their domains. Note that not all certificates are eligible for CT logging. + - If I(ct_log) is not specified, the certificate uses the account default. + - If I(ct_log) is specified and the account settings allow it, I(ct_log) overrides the account default. + - If I(ct_log) is set to C(false), but the account settings are set to "always log", the certificate generation will fail. + type: bool + client_id: + description: + - The client ID to submit the Certificate Signing Request under. + - If no client ID is specified, the certificate will be submitted under the primary client with ID of 1. + - When using a client other than the primary client, the I(org) parameter cannot be specified. + - The issued certificate will have an organization value in the subject distinguished name represented by the client. + type: int + default: 1 + org: + description: + - Organization "O=" to include in the certificate. + - If I(org) is not specified, the organization from the client represented by I(client_id) is used. + - Unless the I(cert_type) is C(PD_SSL), this field may not be specified if the value of I(client_id) is not "1" (the primary client). + non-primary clients, certificates may only be issued with the organization of that client. + type: str + ou: + description: + - Organizational unit "OU=" to include in the certificate. + - I(ou) behavior is dependent on whether organizational units are enabled for your account. If organizational unit support is disabled for your + account, organizational units from the I(csr) and the I(ou) parameter are ignored. + - If both I(csr) and I(ou) are specified, the value in I(ou) will override the OU fields present in the subject distinguished name in the I(csr) + - If neither I(csr) nor I(ou) are specified for a renew or reissue operation, the OU fields in the initial certificate are reused. + - An invalid OU from I(csr) is ignored, but any invalid organizational units in I(ou) will result in an error indicating "Unapproved OU". The I(ou) + parameter can be used to force failure if an unapproved organizational unit is provided. + - A maximum of one OU may be specified for current products. Multiple OUs are reserved for future products. + type: list + elements: str + end_user_key_storage_agreement: + description: + - The end user of the Code Signing certificate must generate and store the private key for this request on cryptographically secure + hardware to be compliant with the Entrust CSP and Subscription agreement. If requesting a certificate of type C(CODE_SIGNING) or + C(EV_CODE_SIGNING), you must set I(end_user_key_storage_agreement) to true if and only if you acknowledge that you will inform the user of this + requirement. + - Applicable only to I(cert_type) of values C(CODE_SIGNING) and C(EV_CODE_SIGNING). + type: bool + tracking_info: + description: Free form tracking information to attach to the record for the certificate. + type: str + requester_name: + description: The requester name to associate with certificate tracking information. + type: str + required: true + requester_email: + description: The requester email to associate with certificate tracking information and receive delivery and expiry notices for the certificate. + type: str + required: true + requester_phone: + description: The requester phone number to associate with certificate tracking information. + type: str + required: true + additional_emails: + description: A list of additional email addresses to receive the delivery notice and expiry notification for the certificate. + type: list + elements: str + custom_fields: + description: + - Mapping of custom fields to associate with the certificate request and certificate. + - Only supported if custom fields are enabled for your account. + - Each custom field specified must be a custom field you have defined for your account. + type: dict + suboptions: + text1: + description: Custom text field (maximum 500 characters) + type: str + text2: + description: Custom text field (maximum 500 characters) + type: str + text3: + description: Custom text field (maximum 500 characters) + type: str + text4: + description: Custom text field (maximum 500 characters) + type: str + text5: + description: Custom text field (maximum 500 characters) + type: str + text6: + description: Custom text field (maximum 500 characters) + type: str + text7: + description: Custom text field (maximum 500 characters) + type: str + text8: + description: Custom text field (maximum 500 characters) + type: str + text9: + description: Custom text field (maximum 500 characters) + type: str + text10: + description: Custom text field (maximum 500 characters) + type: str + text11: + description: Custom text field (maximum 500 characters) + type: str + text12: + description: Custom text field (maximum 500 characters) + type: str + text13: + description: Custom text field (maximum 500 characters) + type: str + text14: + description: Custom text field (maximum 500 characters) + type: str + text15: + description: Custom text field (maximum 500 characters) + type: str + number1: + description: Custom number field. + type: float + number2: + description: Custom number field. + type: float + number3: + description: Custom number field. + type: float + number4: + description: Custom number field. + type: float + number5: + description: Custom number field. + type: float + date1: + description: Custom date field. + type: str + date2: + description: Custom date field. + type: str + date3: + description: Custom date field. + type: str + date4: + description: Custom date field. + type: str + date5: + description: Custom date field. + type: str + email1: + description: Custom email field. + type: str + email2: + description: Custom email field. + type: str + email3: + description: Custom email field. + type: str + email4: + description: Custom email field. + type: str + email5: + description: Custom email field. + type: str + dropdown1: + description: Custom dropdown field. + type: str + dropdown2: + description: Custom dropdown field. + type: str + dropdown3: + description: Custom dropdown field. + type: str + dropdown4: + description: Custom dropdown field. + type: str + dropdown5: + description: Custom dropdown field. + type: str + cert_expiry: + description: + - The date the certificate should be set to expire, in RFC3339 compliant date or date-time format. For example, + C(2020-02-23), C(2020-02-23T15:00:00.05Z). + - I(cert_expiry) is only supported for requests of C(request_type=new) or C(request_type=renew). If C(request_type=reissue), + I(cert_expiry) will be used for the first certificate issuance, but subsequent issuances will have the same expiry as the initial + certificate. + - A reissued certificate will always have the same expiry as the original certificate. + - Note that only the date (day, month, year) is supported for specifying the expiry date. If you choose to specify an expiry time with the expiry + date, the time will be adjusted to Eastern Standard Time (EST). This could have the unintended effect of moving your expiry date to the previous + day. + - Applies only to accounts with a pooling inventory model. + - Only one of I(cert_expiry) or I(cert_lifetime) may be specified. + type: str + cert_lifetime: + description: + - The lifetime of the certificate. + - Applies to all certificates for accounts with a non-pooling inventory model. + - I(cert_lifetime) is only supported for requests of C(request_type=new) or C(request_type=renew). If C(request_type=reissue), I(cert_lifetime) will + be used for the first certificate issuance, but subsequent issuances will have the same expiry as the initial certificate. + - Applies to certificates of I(cert_type)=C(CDS_INDIVIDUAL, CDS_GROUP, CDS_ENT_LITE, CDS_ENT_PRO, SMIME_ENT) for accounts with a pooling inventory + model. + - C(P1Y) is a certificate with a 1 year lifetime. + - C(P2Y) is a certificate with a 2 year lifetime. + - C(P3Y) is a certificate with a 3 year lifetime. + - Only one of I(cert_expiry) or I(cert_lifetime) may be specified. + type: str + choices: [ P1Y, P2Y, P3Y ] +seealso: + - module: community.crypto.openssl_privatekey + description: Can be used to create private keys (both for certificates and accounts). + - module: community.crypto.openssl_csr + description: Can be used to create a Certificate Signing Request (CSR). +''' + +EXAMPLES = r''' +- name: Request a new certificate from Entrust with bare minimum parameters. + Will request a new certificate if current one is valid but within 30 + days of expiry. If replacing an existing file in path, will back it up. + community.crypto.ecs_certificate: + backup: true + path: /etc/ssl/crt/ansible.com.crt + full_chain_path: /etc/ssl/crt/ansible.com.chain.crt + csr: /etc/ssl/csr/ansible.com.csr + cert_type: EV_SSL + requester_name: Jo Doe + requester_email: jdoe@ansible.com + requester_phone: 555-555-5555 + entrust_api_user: apiusername + entrust_api_key: a^lv*32!cd9LnT + entrust_api_client_cert_path: /etc/ssl/entrust/ecs-client.crt + entrust_api_client_cert_key_path: /etc/ssl/entrust/ecs-client.key + +- name: If there is no certificate present in path, request a new certificate + of type EV_SSL. Otherwise, if there is an Entrust managed certificate + in path and it is within 63 days of expiration, request a renew of that + certificate. + community.crypto.ecs_certificate: + path: /etc/ssl/crt/ansible.com.crt + csr: /etc/ssl/csr/ansible.com.csr + cert_type: EV_SSL + cert_expiry: '2020-08-20' + request_type: renew + remaining_days: 63 + requester_name: Jo Doe + requester_email: jdoe@ansible.com + requester_phone: 555-555-5555 + entrust_api_user: apiusername + entrust_api_key: a^lv*32!cd9LnT + entrust_api_client_cert_path: /etc/ssl/entrust/ecs-client.crt + entrust_api_client_cert_key_path: /etc/ssl/entrust/ecs-client.key + +- name: If there is no certificate present in path, download certificate + specified by tracking_id if it is still valid. Otherwise, if the + certificate is within 79 days of expiration, request a renew of that + certificate and save it in path. This can be used to "migrate" a + certificate to be Ansible managed. + community.crypto.ecs_certificate: + path: /etc/ssl/crt/ansible.com.crt + csr: /etc/ssl/csr/ansible.com.csr + tracking_id: 2378915 + request_type: renew + remaining_days: 79 + entrust_api_user: apiusername + entrust_api_key: a^lv*32!cd9LnT + entrust_api_client_cert_path: /etc/ssl/entrust/ecs-client.crt + entrust_api_client_cert_key_path: /etc/ssl/entrust/ecs-client.key + +- name: Force a reissue of the certificate specified by tracking_id. + community.crypto.ecs_certificate: + path: /etc/ssl/crt/ansible.com.crt + force: true + tracking_id: 2378915 + request_type: reissue + entrust_api_user: apiusername + entrust_api_key: a^lv*32!cd9LnT + entrust_api_client_cert_path: /etc/ssl/entrust/ecs-client.crt + entrust_api_client_cert_key_path: /etc/ssl/entrust/ecs-client.key + +- name: Request a new certificate with an alternative client. Note that the + issued certificate will have it's Subject Distinguished Name use the + organization details associated with that client, rather than what is + in the CSR. + community.crypto.ecs_certificate: + path: /etc/ssl/crt/ansible.com.crt + csr: /etc/ssl/csr/ansible.com.csr + client_id: 2 + requester_name: Jo Doe + requester_email: jdoe@ansible.com + requester_phone: 555-555-5555 + entrust_api_user: apiusername + entrust_api_key: a^lv*32!cd9LnT + entrust_api_client_cert_path: /etc/ssl/entrust/ecs-client.crt + entrust_api_client_cert_key_path: /etc/ssl/entrust/ecs-client.key + +- name: Request a new certificate with a number of CSR parameters overridden + and tracking information + community.crypto.ecs_certificate: + path: /etc/ssl/crt/ansible.com.crt + full_chain_path: /etc/ssl/crt/ansible.com.chain.crt + csr: /etc/ssl/csr/ansible.com.csr + subject_alt_name: + - ansible.testcertificates.com + - www.testcertificates.com + eku: SERVER_AND_CLIENT_AUTH + ct_log: true + org: Test Organization Inc. + ou: + - Administration + tracking_info: "Submitted via Ansible" + additional_emails: + - itsupport@testcertificates.com + - jsmith@ansible.com + custom_fields: + text1: Admin + text2: Invoice 25 + number1: 342 + date1: '2018-01-01' + email1: sales@ansible.testcertificates.com + dropdown1: red + cert_expiry: '2020-08-15' + requester_name: Jo Doe + requester_email: jdoe@ansible.com + requester_phone: 555-555-5555 + entrust_api_user: apiusername + entrust_api_key: a^lv*32!cd9LnT + entrust_api_client_cert_path: /etc/ssl/entrust/ecs-client.crt + entrust_api_client_cert_key_path: /etc/ssl/entrust/ecs-client.key + +''' + +RETURN = ''' +filename: + description: The destination path for the generated certificate. + returned: changed or success + type: str + sample: /etc/ssl/crt/www.ansible.com.crt +backup_file: + description: Name of backup file created for the certificate. + returned: changed and if I(backup) is C(true) + type: str + sample: /path/to/www.ansible.com.crt.2019-03-09@11:22~ +backup_full_chain_file: + description: Name of the backup file created for the certificate chain. + returned: changed and if I(backup) is C(true) and I(full_chain_path) is set. + type: str + sample: /path/to/ca.chain.crt.2019-03-09@11:22~ +tracking_id: + description: The tracking ID to reference and track the certificate in ECS. + returned: success + type: int + sample: 380079 +serial_number: + description: The serial number of the issued certificate. + returned: success + type: int + sample: 1235262234164342 +cert_days: + description: The number of days the certificate remains valid. + returned: success + type: int + sample: 253 +cert_status: + description: + - The certificate status in ECS. + - 'Current possible values (which may be expanded in the future) are: C(ACTIVE), C(APPROVED), C(DEACTIVATED), C(DECLINED), C(EXPIRED), C(NA), + C(PENDING), C(PENDING_QUORUM), C(READY), C(REISSUED), C(REISSUING), C(RENEWED), C(RENEWING), C(REVOKED), C(SUSPENDED)' + returned: success + type: str + sample: ACTIVE +cert_details: + description: + - The full response JSON from the Get Certificate call of the ECS API. + - 'While the response contents are guaranteed to be forwards compatible with new ECS API releases, Entrust recommends that you do not make any + playbooks take actions based on the content of this field. However it may be useful for debugging, logging, or auditing purposes.' + returned: success + type: dict + +''' + +from ansible_collections.community.crypto.plugins.module_utils.ecs.api import ( + ecs_client_argument_spec, + ECSClient, + RestOperationException, + SessionConfigurationException, +) + +import datetime +import os +import re +import time +import traceback + +from ansible.module_utils.basic import AnsibleModule, missing_required_lib +from ansible.module_utils.common.text.converters import to_native, to_bytes + +from ansible_collections.community.crypto.plugins.module_utils.version import LooseVersion + +from ansible_collections.community.crypto.plugins.module_utils.io import ( + write_file, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.support import ( + load_certificate, +) + +CRYPTOGRAPHY_IMP_ERR = None +try: + import cryptography + CRYPTOGRAPHY_VERSION = LooseVersion(cryptography.__version__) +except ImportError: + CRYPTOGRAPHY_IMP_ERR = traceback.format_exc() + CRYPTOGRAPHY_FOUND = False +else: + CRYPTOGRAPHY_FOUND = True + +MINIMAL_CRYPTOGRAPHY_VERSION = '1.6' + + +def validate_cert_expiry(cert_expiry): + search_string_partial = re.compile(r'^([0-9]+)-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])\Z') + search_string_full = re.compile(r'^([0-9]+)-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])[Tt]([01][0-9]|2[0-3]):([0-5][0-9]):' + r'([0-5][0-9]|60)(.[0-9]+)?(([Zz])|([+|-]([01][0-9]|2[0-3]):[0-5][0-9]))\Z') + if search_string_partial.match(cert_expiry) or search_string_full.match(cert_expiry): + return True + return False + + +def calculate_cert_days(expires_after): + cert_days = 0 + if expires_after: + expires_after_datetime = datetime.datetime.strptime(expires_after, '%Y-%m-%dT%H:%M:%SZ') + cert_days = (expires_after_datetime - datetime.datetime.now()).days + return cert_days + + +# Populate the value of body[dict_param_name] with the JSON equivalent of +# module parameter of param_name if that parameter is present, otherwise leave field +# out of resulting dict +def convert_module_param_to_json_bool(module, dict_param_name, param_name): + body = {} + if module.params[param_name] is not None: + if module.params[param_name]: + body[dict_param_name] = 'true' + else: + body[dict_param_name] = 'false' + return body + + +class EcsCertificate(object): + ''' + Entrust Certificate Services certificate class. + ''' + + def __init__(self, module): + self.path = module.params['path'] + self.full_chain_path = module.params['full_chain_path'] + self.force = module.params['force'] + self.backup = module.params['backup'] + self.request_type = module.params['request_type'] + self.csr = module.params['csr'] + + # All return values + self.changed = False + self.filename = None + self.tracking_id = None + self.cert_status = None + self.serial_number = None + self.cert_days = None + self.cert_details = None + self.backup_file = None + self.backup_full_chain_file = None + + self.cert = None + self.ecs_client = None + if self.path and os.path.exists(self.path): + try: + self.cert = load_certificate(self.path, backend='cryptography') + except Exception as dummy: + self.cert = None + # Instantiate the ECS client and then try a no-op connection to verify credentials are valid + try: + self.ecs_client = ECSClient( + entrust_api_user=module.params['entrust_api_user'], + entrust_api_key=module.params['entrust_api_key'], + entrust_api_cert=module.params['entrust_api_client_cert_path'], + entrust_api_cert_key=module.params['entrust_api_client_cert_key_path'], + entrust_api_specification_path=module.params['entrust_api_specification_path'] + ) + except SessionConfigurationException as e: + module.fail_json(msg='Failed to initialize Entrust Provider: {0}'.format(to_native(e))) + try: + self.ecs_client.GetAppVersion() + except RestOperationException as e: + module.fail_json(msg='Please verify credential information. Received exception when testing ECS connection: {0}'.format(to_native(e.message))) + + # Conversion of the fields that go into the 'tracking' parameter of the request object + def convert_tracking_params(self, module): + body = {} + tracking = {} + if module.params['requester_name']: + tracking['requesterName'] = module.params['requester_name'] + if module.params['requester_email']: + tracking['requesterEmail'] = module.params['requester_email'] + if module.params['requester_phone']: + tracking['requesterPhone'] = module.params['requester_phone'] + if module.params['tracking_info']: + tracking['trackingInfo'] = module.params['tracking_info'] + if module.params['custom_fields']: + # Omit custom fields from submitted dict if not present, instead of submitting them with value of 'null' + # The ECS API does technically accept null without error, but it complicates debugging user escalations and is unnecessary bandwidth. + custom_fields = {} + for k, v in module.params['custom_fields'].items(): + if v is not None: + custom_fields[k] = v + tracking['customFields'] = custom_fields + if module.params['additional_emails']: + tracking['additionalEmails'] = module.params['additional_emails'] + body['tracking'] = tracking + return body + + def convert_cert_subject_params(self, module): + body = {} + if module.params['subject_alt_name']: + body['subjectAltName'] = module.params['subject_alt_name'] + if module.params['org']: + body['org'] = module.params['org'] + if module.params['ou']: + body['ou'] = module.params['ou'] + return body + + def convert_general_params(self, module): + body = {} + if module.params['eku']: + body['eku'] = module.params['eku'] + if self.request_type == 'new': + body['certType'] = module.params['cert_type'] + body['clientId'] = module.params['client_id'] + body.update(convert_module_param_to_json_bool(module, 'ctLog', 'ct_log')) + body.update(convert_module_param_to_json_bool(module, 'endUserKeyStorageAgreement', 'end_user_key_storage_agreement')) + return body + + def convert_expiry_params(self, module): + body = {} + if module.params['cert_lifetime']: + body['certLifetime'] = module.params['cert_lifetime'] + elif module.params['cert_expiry']: + body['certExpiryDate'] = module.params['cert_expiry'] + # If neither cerTLifetime or certExpiryDate was specified and the request type is new, default to 365 days + elif self.request_type != 'reissue': + gmt_now = datetime.datetime.fromtimestamp(time.mktime(time.gmtime())) + expiry = gmt_now + datetime.timedelta(days=365) + body['certExpiryDate'] = expiry.strftime("%Y-%m-%dT%H:%M:%S.00Z") + return body + + def set_tracking_id_by_serial_number(self, module): + try: + # Use serial_number to identify if certificate is an Entrust Certificate + # with an associated tracking ID + serial_number = "{0:X}".format(self.cert.serial_number) + cert_results = self.ecs_client.GetCertificates(serialNumber=serial_number).get('certificates', {}) + if len(cert_results) == 1: + self.tracking_id = cert_results[0].get('trackingId') + except RestOperationException as dummy: + # If we fail to find a cert by serial number, that's fine, we just do not set self.tracking_id + return + + def set_cert_details(self, module): + try: + self.cert_details = self.ecs_client.GetCertificate(trackingId=self.tracking_id) + self.cert_status = self.cert_details.get('status') + self.serial_number = self.cert_details.get('serialNumber') + self.cert_days = calculate_cert_days(self.cert_details.get('expiresAfter')) + except RestOperationException as e: + module.fail_json('Failed to get details of certificate with tracking_id="{0}", Error: '.format(self.tracking_id), to_native(e.message)) + + def check(self, module): + if self.cert: + # We will only treat a certificate as valid if it is found as a managed entrust cert. + # We will only set updated tracking ID based on certificate in "path" if it is managed by entrust. + self.set_tracking_id_by_serial_number(module) + + if module.params['tracking_id'] and self.tracking_id and module.params['tracking_id'] != self.tracking_id: + module.warn('tracking_id parameter of "{0}" provided, but will be ignored. Valid certificate was present in path "{1}" with ' + 'tracking_id of "{2}".'.format(module.params['tracking_id'], self.path, self.tracking_id)) + + # If we did not end up setting tracking_id based on existing cert, get from module params + if not self.tracking_id: + self.tracking_id = module.params['tracking_id'] + + if not self.tracking_id: + return False + + self.set_cert_details(module) + + if self.cert_status == 'EXPIRED' or self.cert_status == 'SUSPENDED' or self.cert_status == 'REVOKED': + return False + if self.cert_days < module.params['remaining_days']: + return False + + return True + + def request_cert(self, module): + if not self.check(module) or self.force: + body = {} + + # Read the CSR contents + if self.csr and os.path.exists(self.csr): + with open(self.csr, 'r') as csr_file: + body['csr'] = csr_file.read() + + # Check if the path is already a cert + # tracking_id may be set as a parameter or by get_cert_details if an entrust cert is in 'path'. If tracking ID is null + # We will be performing a reissue operation. + if self.request_type != 'new' and not self.tracking_id: + module.warn('No existing Entrust certificate found in path={0} and no tracking_id was provided, setting request_type to "new" for this task' + 'run. Future playbook runs that point to the pathination file in {1} will use request_type={2}' + .format(self.path, self.path, self.request_type)) + self.request_type = 'new' + elif self.request_type == 'new' and self.tracking_id: + module.warn('Existing certificate being acted upon, but request_type is "new", so will be a new certificate issuance rather than a' + 'reissue or renew') + # Use cases where request type is new and no existing certificate, or where request type is reissue/renew and a valid + # existing certificate is found, do not need warnings. + + body.update(self.convert_tracking_params(module)) + body.update(self.convert_cert_subject_params(module)) + body.update(self.convert_general_params(module)) + body.update(self.convert_expiry_params(module)) + + if not module.check_mode: + try: + if self.request_type == 'validate_only': + body['validateOnly'] = 'true' + result = self.ecs_client.NewCertRequest(Body=body) + if self.request_type == 'new': + result = self.ecs_client.NewCertRequest(Body=body) + elif self.request_type == 'renew': + result = self.ecs_client.RenewCertRequest(trackingId=self.tracking_id, Body=body) + elif self.request_type == 'reissue': + result = self.ecs_client.ReissueCertRequest(trackingId=self.tracking_id, Body=body) + self.tracking_id = result.get('trackingId') + self.set_cert_details(module) + except RestOperationException as e: + module.fail_json(msg='Failed to request new certificate from Entrust (ECS) {0}'.format(e.message)) + + if self.request_type != 'validate_only': + if self.backup: + self.backup_file = module.backup_local(self.path) + write_file(module, to_bytes(self.cert_details.get('endEntityCert'))) + if self.full_chain_path and self.cert_details.get('chainCerts'): + if self.backup: + self.backup_full_chain_file = module.backup_local(self.full_chain_path) + chain_string = '\n'.join(self.cert_details.get('chainCerts')) + '\n' + write_file(module, to_bytes(chain_string), path=self.full_chain_path) + self.changed = True + # If there is no certificate present in path but a tracking ID was specified, save it to disk + elif not os.path.exists(self.path) and self.tracking_id: + if not module.check_mode: + write_file(module, to_bytes(self.cert_details.get('endEntityCert'))) + if self.full_chain_path and self.cert_details.get('chainCerts'): + chain_string = '\n'.join(self.cert_details.get('chainCerts')) + '\n' + write_file(module, to_bytes(chain_string), path=self.full_chain_path) + self.changed = True + + def dump(self): + result = { + 'changed': self.changed, + 'filename': self.path, + 'tracking_id': self.tracking_id, + 'cert_status': self.cert_status, + 'serial_number': self.serial_number, + 'cert_days': self.cert_days, + 'cert_details': self.cert_details, + } + if self.backup_file: + result['backup_file'] = self.backup_file + result['backup_full_chain_file'] = self.backup_full_chain_file + return result + + +def custom_fields_spec(): + return dict( + text1=dict(type='str'), + text2=dict(type='str'), + text3=dict(type='str'), + text4=dict(type='str'), + text5=dict(type='str'), + text6=dict(type='str'), + text7=dict(type='str'), + text8=dict(type='str'), + text9=dict(type='str'), + text10=dict(type='str'), + text11=dict(type='str'), + text12=dict(type='str'), + text13=dict(type='str'), + text14=dict(type='str'), + text15=dict(type='str'), + number1=dict(type='float'), + number2=dict(type='float'), + number3=dict(type='float'), + number4=dict(type='float'), + number5=dict(type='float'), + date1=dict(type='str'), + date2=dict(type='str'), + date3=dict(type='str'), + date4=dict(type='str'), + date5=dict(type='str'), + email1=dict(type='str'), + email2=dict(type='str'), + email3=dict(type='str'), + email4=dict(type='str'), + email5=dict(type='str'), + dropdown1=dict(type='str'), + dropdown2=dict(type='str'), + dropdown3=dict(type='str'), + dropdown4=dict(type='str'), + dropdown5=dict(type='str'), + ) + + +def ecs_certificate_argument_spec(): + return dict( + backup=dict(type='bool', default=False), + force=dict(type='bool', default=False), + path=dict(type='path', required=True), + full_chain_path=dict(type='path'), + tracking_id=dict(type='int'), + remaining_days=dict(type='int', default=30), + request_type=dict(type='str', default='new', choices=['new', 'renew', 'reissue', 'validate_only']), + cert_type=dict(type='str', choices=['STANDARD_SSL', + 'ADVANTAGE_SSL', + 'UC_SSL', + 'EV_SSL', + 'WILDCARD_SSL', + 'PRIVATE_SSL', + 'PD_SSL', + 'CODE_SIGNING', + 'EV_CODE_SIGNING', + 'CDS_INDIVIDUAL', + 'CDS_GROUP', + 'CDS_ENT_LITE', + 'CDS_ENT_PRO', + 'SMIME_ENT', + ]), + csr=dict(type='str'), + subject_alt_name=dict(type='list', elements='str'), + eku=dict(type='str', choices=['SERVER_AUTH', 'CLIENT_AUTH', 'SERVER_AND_CLIENT_AUTH']), + ct_log=dict(type='bool'), + client_id=dict(type='int', default=1), + org=dict(type='str'), + ou=dict(type='list', elements='str'), + end_user_key_storage_agreement=dict(type='bool'), + tracking_info=dict(type='str'), + requester_name=dict(type='str', required=True), + requester_email=dict(type='str', required=True), + requester_phone=dict(type='str', required=True), + additional_emails=dict(type='list', elements='str'), + custom_fields=dict(type='dict', default=None, options=custom_fields_spec()), + cert_expiry=dict(type='str'), + cert_lifetime=dict(type='str', choices=['P1Y', 'P2Y', 'P3Y']), + ) + + +def main(): + ecs_argument_spec = ecs_client_argument_spec() + ecs_argument_spec.update(ecs_certificate_argument_spec()) + module = AnsibleModule( + argument_spec=ecs_argument_spec, + required_if=( + ['request_type', 'new', ['cert_type']], + ['request_type', 'validate_only', ['cert_type']], + ['cert_type', 'CODE_SIGNING', ['end_user_key_storage_agreement']], + ['cert_type', 'EV_CODE_SIGNING', ['end_user_key_storage_agreement']], + ), + mutually_exclusive=( + ['cert_expiry', 'cert_lifetime'], + ), + supports_check_mode=True, + ) + + if not CRYPTOGRAPHY_FOUND or CRYPTOGRAPHY_VERSION < LooseVersion(MINIMAL_CRYPTOGRAPHY_VERSION): + module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)), + exception=CRYPTOGRAPHY_IMP_ERR) + + # If validate_only is used, pointing to an existing tracking_id is an invalid operation + if module.params['tracking_id']: + if module.params['request_type'] == 'new' or module.params['request_type'] == 'validate_only': + module.fail_json(msg='The tracking_id field is invalid when request_type="{0}".'.format(module.params['request_type'])) + + # A reissued request can not specify an expiration date or lifetime + if module.params['request_type'] == 'reissue': + if module.params['cert_expiry']: + 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: + 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'])) + elif not os.path.exists(module_params_csr): + module.fail_json(msg='The csr field of {0} was not a valid path. csr is required when request_type={1}'.format( + module_params_csr, module.params['request_type'])) + + if module.params['ou'] and len(module.params['ou']) > 1: + module.fail_json(msg='Multiple "ou" values are not currently supported.') + + if module.params['end_user_key_storage_agreement']: + if module.params['cert_type'] != 'CODE_SIGNING' and module.params['cert_type'] != 'EV_CODE_SIGNING': + module.fail_json(msg='Parameter "end_user_key_storage_agreement" is valid only for cert_types "CODE_SIGNING" and "EV_CODE_SIGNING"') + + if module.params['org'] and module.params['client_id'] != 1 and module.params['cert_type'] != 'PD_SSL': + module.fail_json(msg='The "org" parameter is not supported when client_id parameter is set to a value other than 1, unless cert_type is "PD_SSL".') + + if module.params['cert_expiry']: + if not validate_cert_expiry(module.params['cert_expiry']): + module.fail_json(msg='The "cert_expiry" parameter of "{0}" is not a valid date or date-time'.format(module.params['cert_expiry'])) + + certificate = EcsCertificate(module) + certificate.request_cert(module) + result = certificate.dump() + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/crypto/plugins/modules/ecs_domain.py b/ansible_collections/community/crypto/plugins/modules/ecs_domain.py new file mode 100644 index 00000000..ec7ad98b --- /dev/null +++ b/ansible_collections/community/crypto/plugins/modules/ecs_domain.py @@ -0,0 +1,412 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright 2019 Entrust Datacard Corporation. +# 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: ecs_domain +author: + - Chris Trufan (@ctrufan) +version_added: '1.0.0' +short_description: Request validation of a domain with the Entrust Certificate Services (ECS) API +description: + - Request validation or re-validation of a domain with the Entrust Certificate Services (ECS) API. + - Requires credentials for the L(Entrust Certificate Services,https://www.entrustdatacard.com/products/categories/ssl-certificates) (ECS) API. + - If the domain is already in the validation process, no new validation will be requested, but the validation data (if applicable) will be returned. + - If the domain is already in the validation process but the I(verification_method) specified is different than the current I(verification_method), + the I(verification_method) will be updated and validation data (if applicable) will be returned. + - If the domain is an active, validated domain, the return value of I(changed) will be false, unless C(domain_status=EXPIRED), in which case a re-validation + will be performed. + - If C(verification_method=dns), details about the required DNS entry will be specified in the return parameters I(dns_contents), I(dns_location), and + I(dns_resource_type). + - If C(verification_method=web_server), details about the required file details will be specified in the return parameters I(file_contents) and + I(file_location). + - If C(verification_method=email), the email address(es) that the validation email(s) were sent to will be in the return parameter I(emails). This is + purely informational. For domains requested using this module, this will always be a list of size 1. +notes: + - There is a small delay (typically about 5 seconds, but can be as long as 60 seconds) before obtaining the random values when requesting a validation + while C(verification_method=dns) or C(verification_method=web_server). Be aware of that if doing many domain validation requests. +extends_documentation_fragment: + - community.crypto.attributes + - community.crypto.ecs_credential +attributes: + check_mode: + support: none + diff_mode: + support: none +options: + client_id: + description: + - The client ID to request the domain be associated with. + - If no client ID is specified, the domain will be added under the primary client with ID of 1. + type: int + default: 1 + domain_name: + description: + - The domain name to be verified or reverified. + type: str + required: true + verification_method: + description: + - The verification method to be used to prove control of the domain. + - If C(verification_method=email) and the value I(verification_email) is specified, that value is used for the email validation. If + I(verification_email) is not provided, the first value present in WHOIS data will be used. An email will be sent to the address in + I(verification_email) with instructions on how to verify control of the domain. + - If C(verification_method=dns), the value I(dns_contents) must be stored in location I(dns_location), with a DNS record type of + I(verification_dns_record_type). To prove domain ownership, update your DNS records so the text string returned by I(dns_contents) is available at + I(dns_location). + - If C(verification_method=web_server), the contents of return value I(file_contents) must be made available on a web server accessible at location + I(file_location). + - If C(verification_method=manual), the domain will be validated with a manual process. This is not recommended. + type: str + choices: [ 'dns', 'email', 'manual', 'web_server'] + required: true + verification_email: + description: + - Email address to be used to verify domain ownership. + - 'Email address must be either an email address present in the WHOIS data for I(domain_name), or one of the following constructed emails: + admin@I(domain_name), administrator@I(domain_name), webmaster@I(domain_name), hostmaster@I(domain_name), postmaster@I(domain_name).' + - 'Note that if I(domain_name) includes subdomains, the top level domain should be used. For example, if requesting validation of + example1.ansible.com, or test.example2.ansible.com, and you want to use the "admin" preconstructed name, the email address should be + admin@ansible.com.' + - If using the email values from the WHOIS data for the domain or its top level namespace, they must be exact matches. + - If C(verification_method=email) but I(verification_email) is not provided, the first email address found in WHOIS data for the domain will be + used. + - To verify domain ownership, domain owner must follow the instructions in the email they receive. + - Only allowed if C(verification_method=email) + type: str +seealso: + - module: community.crypto.x509_certificate + description: Can be used to request certificates from ECS, with C(provider=entrust). + - module: community.crypto.ecs_certificate + description: Can be used to request a Certificate from ECS using a verified domain. +''' + +EXAMPLES = r''' +- name: Request domain validation using email validation for client ID of 2. + community.crypto.ecs_domain: + domain_name: ansible.com + client_id: 2 + verification_method: email + verification_email: admin@ansible.com + entrust_api_user: apiusername + entrust_api_key: a^lv*32!cd9LnT + entrust_api_client_cert_path: /etc/ssl/entrust/ecs-client.crt + entrust_api_client_cert_key_path: /etc/ssl/entrust/ecs-client.key + +- name: Request domain validation using DNS. If domain is already valid, + request revalidation if expires within 90 days + community.crypto.ecs_domain: + domain_name: ansible.com + verification_method: dns + entrust_api_user: apiusername + entrust_api_key: a^lv*32!cd9LnT + entrust_api_client_cert_path: /etc/ssl/entrust/ecs-client.crt + entrust_api_client_cert_key_path: /etc/ssl/entrust/ecs-client.key + +- name: Request domain validation using web server validation, and revalidate + if fewer than 60 days remaining of EV eligibility. + community.crypto.ecs_domain: + domain_name: ansible.com + verification_method: web_server + entrust_api_user: apiusername + entrust_api_key: a^lv*32!cd9LnT + entrust_api_client_cert_path: /etc/ssl/entrust/ecs-client.crt + entrust_api_client_cert_key_path: /etc/ssl/entrust/ecs-client.key + +- name: Request domain validation using manual validation. + community.crypto.ecs_domain: + domain_name: ansible.com + verification_method: manual + entrust_api_user: apiusername + entrust_api_key: a^lv*32!cd9LnT + entrust_api_client_cert_path: /etc/ssl/entrust/ecs-client.crt + entrust_api_client_cert_key_path: /etc/ssl/entrust/ecs-client.key +''' + +RETURN = ''' +domain_status: + description: Status of the current domain. Will be one of C(APPROVED), C(DECLINED), C(CANCELLED), C(INITIAL_VERIFICATION), C(DECLINED), C(CANCELLED), + C(RE_VERIFICATION), C(EXPIRED), C(EXPIRING) + returned: changed or success + type: str + sample: APPROVED +verification_method: + description: Verification method used to request the domain validation. If C(changed) will be the same as I(verification_method) input parameter. + returned: changed or success + type: str + sample: dns +file_location: + description: The location that ECS will be expecting to be able to find the file for domain verification, containing the contents of I(file_contents). + returned: I(verification_method) is C(web_server) + type: str + sample: http://ansible.com/.well-known/pki-validation/abcd.txt +file_contents: + description: The contents of the file that ECS will be expecting to find at C(file_location). + returned: I(verification_method) is C(web_server) + type: str + sample: AB23CD41432522FF2526920393982FAB +emails: + description: + - The list of emails used to request validation of this domain. + - Domains requested using this module will only have a list of size 1. + returned: I(verification_method) is C(email) + type: list + sample: [ admin@ansible.com, administrator@ansible.com ] +dns_location: + description: The location that ECS will be expecting to be able to find the DNS entry for domain verification, containing the contents of I(dns_contents). + returned: changed and if I(verification_method) is C(dns) + type: str + sample: _pki-validation.ansible.com +dns_contents: + description: The value that ECS will be expecting to find in the DNS record located at I(dns_location). + returned: changed and if I(verification_method) is C(dns) + type: str + sample: AB23CD41432522FF2526920393982FAB +dns_resource_type: + description: The type of resource record that ECS will be expecting for the DNS record located at I(dns_location). + returned: changed and if I(verification_method) is C(dns) + type: str + sample: TXT +client_id: + description: Client ID that the domain belongs to. If the input value I(client_id) is specified, this will always be the same as I(client_id) + returned: changed or success + type: int + sample: 1 +ov_eligible: + description: Whether the domain is eligible for submission of "OV" certificates. Will never be C(false) if I(ov_eligible) is C(true) + returned: success and I(domain_status) is C(APPROVED), C(RE_VERIFICATION), C(EXPIRING), or C(EXPIRED). + type: bool + sample: true +ov_days_remaining: + description: The number of days the domain remains eligible for submission of "OV" certificates. Will never be less than the value of I(ev_days_remaining) + returned: success and I(ov_eligible) is C(true) and I(domain_status) is C(APPROVED), C(RE_VERIFICATION) or C(EXPIRING). + type: int + sample: 129 +ev_eligible: + description: Whether the domain is eligible for submission of "EV" certificates. Will never be C(true) if I(ov_eligible) is C(false) + returned: success and I(domain_status) is C(APPROVED), C(RE_VERIFICATION) or C(EXPIRING), or C(EXPIRED). + type: bool + sample: true +ev_days_remaining: + description: The number of days the domain remains eligible for submission of "EV" certificates. Will never be greater than the value of + I(ov_days_remaining) + returned: success and I(ev_eligible) is C(true) and I(domain_status) is C(APPROVED), C(RE_VERIFICATION) or C(EXPIRING). + type: int + sample: 94 + +''' + +import datetime +import time + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.common.text.converters import to_native + +from ansible_collections.community.crypto.plugins.module_utils.ecs.api import ( + ecs_client_argument_spec, + ECSClient, + RestOperationException, + SessionConfigurationException, +) + + +def calculate_days_remaining(expiry_date): + days_remaining = None + if expiry_date: + expiry_datetime = datetime.datetime.strptime(expiry_date, '%Y-%m-%dT%H:%M:%SZ') + days_remaining = (expiry_datetime - datetime.datetime.now()).days + return days_remaining + + +class EcsDomain(object): + ''' + Entrust Certificate Services domain class. + ''' + + def __init__(self, module): + self.changed = False + self.domain_status = None + self.verification_method = None + self.file_location = None + self.file_contents = None + self.dns_location = None + self.dns_contents = None + self.dns_resource_type = None + self.emails = None + self.ov_eligible = None + self.ov_days_remaining = None + self.ev_eligble = None + self.ev_days_remaining = None + # Note that verification_method is the 'current' verification + # method of the domain, we'll use module.params when requesting a new + # one, in case the verification method has changed. + self.verification_method = None + + self.ecs_client = None + # Instantiate the ECS client and then try a no-op connection to verify credentials are valid + try: + self.ecs_client = ECSClient( + entrust_api_user=module.params['entrust_api_user'], + entrust_api_key=module.params['entrust_api_key'], + entrust_api_cert=module.params['entrust_api_client_cert_path'], + entrust_api_cert_key=module.params['entrust_api_client_cert_key_path'], + entrust_api_specification_path=module.params['entrust_api_specification_path'] + ) + except SessionConfigurationException as e: + module.fail_json(msg='Failed to initialize Entrust Provider: {0}'.format(to_native(e))) + try: + self.ecs_client.GetAppVersion() + except RestOperationException as e: + module.fail_json(msg='Please verify credential information. Received exception when testing ECS connection: {0}'.format(to_native(e.message))) + + def set_domain_details(self, domain_details): + if domain_details.get('verificationMethod'): + self.verification_method = domain_details['verificationMethod'].lower() + self.domain_status = domain_details['verificationStatus'] + self.ov_eligible = domain_details.get('ovEligible') + self.ov_days_remaining = calculate_days_remaining(domain_details.get('ovExpiry')) + self.ev_eligible = domain_details.get('evEligible') + self.ev_days_remaining = calculate_days_remaining(domain_details.get('evExpiry')) + self.client_id = domain_details['clientId'] + + if self.verification_method == 'dns' and domain_details.get('dnsMethod'): + self.dns_location = domain_details['dnsMethod']['recordDomain'] + self.dns_resource_type = domain_details['dnsMethod']['recordType'] + self.dns_contents = domain_details['dnsMethod']['recordValue'] + elif self.verification_method == 'web_server' and domain_details.get('webServerMethod'): + self.file_location = domain_details['webServerMethod']['fileLocation'] + self.file_contents = domain_details['webServerMethod']['fileContents'] + elif self.verification_method == 'email' and domain_details.get('emailMethod'): + self.emails = domain_details['emailMethod'] + + def check(self, module): + try: + domain_details = self.ecs_client.GetDomain(clientId=module.params['client_id'], domain=module.params['domain_name']) + self.set_domain_details(domain_details) + if self.domain_status != 'APPROVED' and self.domain_status != 'INITIAL_VERIFICATION' and self.domain_status != 'RE_VERIFICATION': + return False + + # If domain verification is in process, we want to return the random values and treat it as a valid. + if self.domain_status == 'INITIAL_VERIFICATION' or self.domain_status == 'RE_VERIFICATION': + # Unless the verification method has changed, in which case we need to do a reverify request. + if self.verification_method != module.params['verification_method']: + return False + + if self.domain_status == 'EXPIRING': + return False + + return True + except RestOperationException as dummy: + return False + + def request_domain(self, module): + if not self.check(module): + body = {} + + body['verificationMethod'] = module.params['verification_method'].upper() + if module.params['verification_method'] == 'email': + emailMethod = {} + if module.params['verification_email']: + emailMethod['emailSource'] = 'SPECIFIED' + emailMethod['email'] = module.params['verification_email'] + else: + emailMethod['emailSource'] = 'INCLUDE_WHOIS' + body['emailMethod'] = emailMethod + # Only populate domain name in body if it is not an existing domain + if not self.domain_status: + body['domainName'] = module.params['domain_name'] + try: + if not self.domain_status: + self.ecs_client.AddDomain(clientId=module.params['client_id'], Body=body) + else: + self.ecs_client.ReverifyDomain(clientId=module.params['client_id'], domain=module.params['domain_name'], Body=body) + + time.sleep(5) + result = self.ecs_client.GetDomain(clientId=module.params['client_id'], domain=module.params['domain_name']) + + # It takes a bit of time before the random values are available + if module.params['verification_method'] == 'dns' or module.params['verification_method'] == 'web_server': + for i in range(4): + # Check both that random values are now available, and that they're different than were populated by previous 'check' + if module.params['verification_method'] == 'dns': + if result.get('dnsMethod') and result['dnsMethod']['recordValue'] != self.dns_contents: + break + elif module.params['verification_method'] == 'web_server': + if result.get('webServerMethod') and result['webServerMethod']['fileContents'] != self.file_contents: + break + time.sleep(10) + result = self.ecs_client.GetDomain(clientId=module.params['client_id'], domain=module.params['domain_name']) + self.changed = True + self.set_domain_details(result) + except RestOperationException as e: + module.fail_json(msg='Failed to request domain validation from Entrust (ECS) {0}'.format(e.message)) + + def dump(self): + result = { + 'changed': self.changed, + 'client_id': self.client_id, + 'domain_status': self.domain_status, + } + + if self.verification_method: + result['verification_method'] = self.verification_method + if self.ov_eligible is not None: + result['ov_eligible'] = self.ov_eligible + if self.ov_days_remaining: + result['ov_days_remaining'] = self.ov_days_remaining + if self.ev_eligible is not None: + result['ev_eligible'] = self.ev_eligible + if self.ev_days_remaining: + result['ev_days_remaining'] = self.ev_days_remaining + if self.emails: + result['emails'] = self.emails + + if self.verification_method == 'dns': + result['dns_location'] = self.dns_location + result['dns_contents'] = self.dns_contents + result['dns_resource_type'] = self.dns_resource_type + elif self.verification_method == 'web_server': + result['file_location'] = self.file_location + result['file_contents'] = self.file_contents + elif self.verification_method == 'email': + result['emails'] = self.emails + + return result + + +def ecs_domain_argument_spec(): + return dict( + client_id=dict(type='int', default=1), + domain_name=dict(type='str', required=True), + verification_method=dict(type='str', required=True, choices=['dns', 'email', 'manual', 'web_server']), + verification_email=dict(type='str'), + ) + + +def main(): + ecs_argument_spec = ecs_client_argument_spec() + ecs_argument_spec.update(ecs_domain_argument_spec()) + module = AnsibleModule( + argument_spec=ecs_argument_spec, + supports_check_mode=False, + ) + + if module.params['verification_email'] and module.params['verification_method'] != 'email': + module.fail_json(msg='The verification_email field is invalid when verification_method="{0}".'.format(module.params['verification_method'])) + + domain = EcsDomain(module) + domain.request_domain(module) + result = domain.dump() + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/crypto/plugins/modules/get_certificate.py b/ansible_collections/community/crypto/plugins/modules/get_certificate.py new file mode 100644 index 00000000..066930b0 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/modules/get_certificate.py @@ -0,0 +1,397 @@ +#!/usr/bin/python +# coding: utf-8 -*- + +# Copyright (c) Ansible Project +# 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: get_certificate +author: "John Westcott IV (@john-westcott-iv)" +short_description: Get a certificate from a host:port +description: + - Makes a secure connection and returns information about the presented certificate + - The module uses the cryptography Python library. + - Support SNI (L(Server Name Indication,https://en.wikipedia.org/wiki/Server_Name_Indication)) only with python >= 2.7. +extends_documentation_fragment: + - community.crypto.attributes +attributes: + check_mode: + support: none + details: + - This action does not modify state. + diff_mode: + support: N/A + details: + - This action does not modify state. +options: + host: + description: + - The host to get the cert for (IP is fine) + type: str + required: true + ca_cert: + description: + - A PEM file containing one or more root certificates; if present, the cert will be validated against these root certs. + - Note that this only validates the certificate is signed by the chain; not that the cert is valid for the host presenting it. + type: path + port: + description: + - The port to connect to + type: int + required: true + server_name: + description: + - Server name used for SNI (L(Server Name Indication,https://en.wikipedia.org/wiki/Server_Name_Indication)) when hostname + is an IP or is different from server name. + type: str + version_added: 1.4.0 + proxy_host: + description: + - Proxy host used when get a certificate. + type: str + proxy_port: + description: + - Proxy port used when get a certificate. + type: int + default: 8080 + starttls: + description: + - Requests a secure connection for protocols which require clients to initiate encryption. + - Only available for C(mysql) currently. + type: str + choices: + - mysql + version_added: 1.9.0 + timeout: + description: + - The timeout in seconds + type: int + default: 10 + select_crypto_backend: + description: + - Determines which crypto backend to use. + - The default choice is C(auto), which tries to use C(cryptography) if available. + - If set to C(cryptography), will try to use the L(cryptography,https://cryptography.io/) library. + type: str + default: auto + choices: [ auto, cryptography ] + ciphers: + description: + - SSL/TLS Ciphers to use for the request. + - 'When a list is provided, all ciphers are joined in order with C(:).' + - See the L(OpenSSL Cipher List Format,https://www.openssl.org/docs/manmaster/man1/openssl-ciphers.html#CIPHER-LIST-FORMAT) + for more details. + - The available ciphers is dependent on the Python and OpenSSL/LibreSSL versions. + type: list + elements: str + version_added: 2.11.0 + +notes: + - When using ca_cert on OS X it has been reported that in some conditions the validate will always succeed. + +requirements: + - "python >= 2.7 when using C(proxy_host)" + - "cryptography >= 1.6" +''' + +RETURN = ''' +cert: + description: The certificate retrieved from the port + returned: success + type: str +expired: + description: Boolean indicating if the cert is expired + returned: success + type: bool +extensions: + description: Extensions applied to the cert + returned: success + type: list + elements: dict + contains: + critical: + returned: success + type: bool + description: Whether the extension is critical. + asn1_data: + returned: success + type: str + description: + - The Base64 encoded ASN.1 content of the extension. + - B(Note) that depending on the C(cryptography) version used, it is + not possible to extract the ASN.1 content of the extension, but only + to provide the re-encoded content of the extension in case it was + parsed by C(cryptography). This should usually result in exactly the + same value, except if the original extension value was malformed. + name: + returned: success + type: str + description: The extension's name. +issuer: + description: Information about the issuer of the cert + returned: success + type: dict +not_after: + description: Expiration date of the cert + returned: success + type: str +not_before: + description: Issue date of the cert + returned: success + type: str +serial_number: + description: The serial number of the cert + returned: success + type: str +signature_algorithm: + description: The algorithm used to sign the cert + returned: success + type: str +subject: + description: Information about the subject of the cert (OU, CN, etc) + returned: success + type: dict +version: + description: The version number of the certificate + returned: success + type: str +''' + +EXAMPLES = ''' +- name: Get the cert from an RDP port + community.crypto.get_certificate: + host: "1.2.3.4" + port: 3389 + delegate_to: localhost + run_once: true + register: cert + +- name: Get a cert from an https port + community.crypto.get_certificate: + host: "www.google.com" + port: 443 + delegate_to: localhost + run_once: true + register: cert + +- name: How many days until cert expires + debug: + msg: "cert expires in: {{ expire_days }} days." + vars: + expire_days: "{{ (( cert.not_after | to_datetime('%Y%m%d%H%M%SZ')) - (ansible_date_time.iso8601 | to_datetime('%Y-%m-%dT%H:%M:%SZ')) ).days }}" +''' + +import atexit +import base64 +import datetime +import traceback + +from os.path import isfile +from socket import create_connection, setdefaulttimeout, socket +from ssl import get_server_certificate, DER_cert_to_PEM_cert, CERT_NONE, CERT_REQUIRED + +from ansible.module_utils.basic import AnsibleModule, 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.crypto.cryptography_support import ( + cryptography_oid_to_name, + cryptography_get_extensions_from_cert, +) + +MINIMAL_CRYPTOGRAPHY_VERSION = '1.6' + +CREATE_DEFAULT_CONTEXT_IMP_ERR = None +try: + from ssl import create_default_context +except ImportError: + CREATE_DEFAULT_CONTEXT_IMP_ERR = traceback.format_exc() + HAS_CREATE_DEFAULT_CONTEXT = False +else: + HAS_CREATE_DEFAULT_CONTEXT = True + +CRYPTOGRAPHY_IMP_ERR = None +try: + import cryptography + import cryptography.exceptions + import cryptography.x509 + from cryptography.hazmat.backends import default_backend as cryptography_backend + CRYPTOGRAPHY_VERSION = LooseVersion(cryptography.__version__) +except ImportError: + CRYPTOGRAPHY_IMP_ERR = traceback.format_exc() + CRYPTOGRAPHY_FOUND = False +else: + CRYPTOGRAPHY_FOUND = True + + +def send_starttls_packet(sock, server_type): + if server_type == 'mysql': + ssl_request_packet = ( + b'\x20\x00\x00\x01\x85\xae\x7f\x00' + + b'\x00\x00\x00\x01\x21\x00\x00\x00' + + b'\x00\x00\x00\x00\x00\x00\x00\x00' + + b'\x00\x00\x00\x00\x00\x00\x00\x00' + + b'\x00\x00\x00\x00' + ) + + sock.recv(8192) # discard initial handshake from server for this naive implementation + sock.send(ssl_request_packet) + + +def main(): + module = AnsibleModule( + argument_spec=dict( + ca_cert=dict(type='path'), + host=dict(type='str', required=True), + port=dict(type='int', required=True), + proxy_host=dict(type='str'), + proxy_port=dict(type='int', default=8080), + server_name=dict(type='str'), + timeout=dict(type='int', default=10), + select_crypto_backend=dict(type='str', choices=['auto', 'cryptography'], default='auto'), + starttls=dict(type='str', choices=['mysql']), + ciphers=dict(type='list', elements='str'), + ), + ) + + ca_cert = module.params.get('ca_cert') + host = module.params.get('host') + port = module.params.get('port') + proxy_host = module.params.get('proxy_host') + proxy_port = module.params.get('proxy_port') + timeout = module.params.get('timeout') + server_name = module.params.get('server_name') + start_tls_server_type = module.params.get('starttls') + ciphers = module.params.get('ciphers') + + backend = module.params.get('select_crypto_backend') + if backend == 'auto': + # Detection what is possible + can_use_cryptography = CRYPTOGRAPHY_FOUND and CRYPTOGRAPHY_VERSION >= LooseVersion(MINIMAL_CRYPTOGRAPHY_VERSION) + + # Try cryptography + if can_use_cryptography: + backend = 'cryptography' + + # Success? + if backend == 'auto': + module.fail_json(msg=("Cannot detect the required Python library " + "cryptography (>= {0})").format(MINIMAL_CRYPTOGRAPHY_VERSION)) + + if backend == 'cryptography': + if not CRYPTOGRAPHY_FOUND: + module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)), + exception=CRYPTOGRAPHY_IMP_ERR) + + result = dict( + changed=False, + ) + + if timeout: + setdefaulttimeout(timeout) + + if ca_cert: + if not isfile(ca_cert): + module.fail_json(msg="ca_cert file does not exist") + + if not HAS_CREATE_DEFAULT_CONTEXT: + # Python < 2.7.9 + if proxy_host: + module.fail_json(msg='To use proxy_host, you must run the get_certificate module with Python 2.7 or newer.', + exception=CREATE_DEFAULT_CONTEXT_IMP_ERR) + if ciphers is not None: + module.fail_json(msg='To use ciphers, you must run the get_certificate module with Python 2.7 or newer.', + exception=CREATE_DEFAULT_CONTEXT_IMP_ERR) + try: + # Note: get_server_certificate does not support SNI! + cert = get_server_certificate((host, port), ca_certs=ca_cert) + except Exception as e: + module.fail_json(msg="Failed to get cert from {0}:{1}, error: {2}".format(host, port, e)) + else: + # Python >= 2.7.9 + try: + if proxy_host: + connect = "CONNECT %s:%s HTTP/1.0\r\n\r\n" % (host, port) + sock = socket() + atexit.register(sock.close) + sock.connect((proxy_host, proxy_port)) + sock.send(connect.encode()) + sock.recv(8192) + else: + sock = create_connection((host, port)) + atexit.register(sock.close) + + if ca_cert: + ctx = create_default_context(cafile=ca_cert) + ctx.check_hostname = False + ctx.verify_mode = CERT_REQUIRED + else: + ctx = create_default_context() + ctx.check_hostname = False + ctx.verify_mode = CERT_NONE + + if start_tls_server_type is not None: + send_starttls_packet(sock, start_tls_server_type) + + if ciphers is not None: + ciphers_joined = ":".join(ciphers) + ctx.set_ciphers(ciphers_joined) + + cert = ctx.wrap_socket(sock, server_hostname=server_name or host).getpeercert(True) + cert = DER_cert_to_PEM_cert(cert) + except Exception as e: + if proxy_host: + module.fail_json(msg="Failed to get cert via proxy {0}:{1} from {2}:{3}, error: {4}".format( + proxy_host, proxy_port, host, port, e)) + else: + module.fail_json(msg="Failed to get cert from {0}:{1}, error: {2}".format(host, port, e)) + + result['cert'] = cert + + if backend == 'cryptography': + x509 = cryptography.x509.load_pem_x509_certificate(to_bytes(cert), cryptography_backend()) + result['subject'] = {} + for attribute in x509.subject: + result['subject'][cryptography_oid_to_name(attribute.oid, short=True)] = attribute.value + + result['expired'] = x509.not_valid_after < datetime.datetime.utcnow() + + result['extensions'] = [] + for dotted_number, entry in cryptography_get_extensions_from_cert(x509).items(): + oid = cryptography.x509.oid.ObjectIdentifier(dotted_number) + result['extensions'].append({ + 'critical': entry['critical'], + 'asn1_data': base64.b64decode(entry['value']), + 'name': cryptography_oid_to_name(oid, short=True), + }) + + result['issuer'] = {} + for attribute in x509.issuer: + result['issuer'][cryptography_oid_to_name(attribute.oid, short=True)] = attribute.value + + result['not_after'] = x509.not_valid_after.strftime('%Y%m%d%H%M%SZ') + result['not_before'] = x509.not_valid_before.strftime('%Y%m%d%H%M%SZ') + + result['serial_number'] = x509.serial_number + result['signature_algorithm'] = cryptography_oid_to_name(x509.signature_algorithm_oid) + + # We need the -1 offset to get the same values as pyOpenSSL + if x509.version == cryptography.x509.Version.v1: + result['version'] = 1 - 1 + elif x509.version == cryptography.x509.Version.v3: + result['version'] = 3 - 1 + else: + result['version'] = "unknown" + + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/crypto/plugins/modules/luks_device.py b/ansible_collections/community/crypto/plugins/modules/luks_device.py new file mode 100644 index 00000000..d8b70e74 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/modules/luks_device.py @@ -0,0 +1,1031 @@ +#!/usr/bin/python +# Copyright (c) 2017 Ansible Project +# 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: luks_device + +short_description: Manage encrypted (LUKS) devices + +description: + - "Module manages L(LUKS,https://en.wikipedia.org/wiki/Linux_Unified_Key_Setup) + on given device. Supports creating, destroying, opening and closing of + LUKS container and adding or removing new keys and passphrases." + +extends_documentation_fragment: + - community.crypto.attributes + +attributes: + check_mode: + support: full + diff_mode: + support: none + +options: + device: + description: + - "Device to work with (for example C(/dev/sda1)). Needed in most cases. + Can be omitted only when I(state=closed) together with I(name) + is provided." + type: str + state: + description: + - "Desired state of the LUKS container. Based on its value creates, + destroys, opens or closes the LUKS container on a given device." + - "I(present) will create LUKS container unless already present. + Requires I(device) and either I(keyfile) or I(passphrase) options + to be provided." + - "I(absent) will remove existing LUKS container if it exists. + Requires I(device) or I(name) to be specified." + - "I(opened) will unlock the LUKS container. If it does not exist + it will be created first. + Requires I(device) and either I(keyfile) or I(passphrase) + to be specified. Use the I(name) option to set the name of + the opened container. Otherwise the name will be + generated automatically and returned as a part of the + result." + - "I(closed) will lock the LUKS container. However if the container + does not exist it will be created. + Requires I(device) and either I(keyfile) or I(passphrase) + options to be provided. If container does already exist + I(device) or I(name) will suffice." + type: str + default: present + choices: [present, absent, opened, closed] + name: + description: + - "Sets container name when I(state=opened). Can be used + instead of I(device) when closing the existing container + (that is, when I(state=closed))." + type: str + keyfile: + description: + - "Used to unlock the container. Either a I(keyfile) or a + I(passphrase) is needed for most of the operations. Parameter + value is the path to the keyfile with the passphrase." + - "BEWARE that working with keyfiles in plaintext is dangerous. + Make sure that they are protected." + type: path + passphrase: + description: + - "Used to unlock the container. Either a I(passphrase) or a + I(keyfile) is needed for most of the operations. Parameter + value is a string with the passphrase." + type: str + version_added: '1.0.0' + keysize: + description: + - "Sets the key size only if LUKS container does not exist." + type: int + version_added: '1.0.0' + new_keyfile: + description: + - "Adds additional key to given container on I(device). + Needs I(keyfile) or I(passphrase) option for authorization. + LUKS container supports up to 8 keyslots. Parameter value + is the path to the keyfile with the passphrase." + - "NOTE that adding additional keys is idempotent only since + community.crypto 1.4.0. For older versions, a new keyslot + will be used even if another keyslot already exists for this + keyfile." + - "BEWARE that working with keyfiles in plaintext is dangerous. + Make sure that they are protected." + type: path + new_passphrase: + description: + - "Adds additional passphrase to given container on I(device). + Needs I(keyfile) or I(passphrase) option for authorization. LUKS + container supports up to 8 keyslots. Parameter value is a string + with the new passphrase." + - "NOTE that adding additional passphrase is idempotent only since + community.crypto 1.4.0. For older versions, a new keyslot will + be used even if another keyslot already exists for this passphrase." + type: str + version_added: '1.0.0' + remove_keyfile: + description: + - "Removes given key from the container on I(device). Does not + remove the keyfile from filesystem. + Parameter value is the path to the keyfile with the passphrase." + - "NOTE that removing keys is idempotent only since + community.crypto 1.4.0. For older versions, trying to remove + a key which no longer exists results in an error." + - "NOTE that to remove the last key from a LUKS container, the + I(force_remove_last_key) option must be set to C(true)." + - "BEWARE that working with keyfiles in plaintext is dangerous. + Make sure that they are protected." + type: path + remove_passphrase: + description: + - "Removes given passphrase from the container on I(device). + Parameter value is a string with the passphrase to remove." + - "NOTE that removing passphrases is idempotent only since + community.crypto 1.4.0. For older versions, trying to remove + a passphrase which no longer exists results in an error." + - "NOTE that to remove the last keyslot from a LUKS + container, the I(force_remove_last_key) option must be set + to C(true)." + type: str + version_added: '1.0.0' + force_remove_last_key: + description: + - "If set to C(true), allows removing the last key from a container." + - "BEWARE that when the last key has been removed from a container, + the container can no longer be opened!" + type: bool + default: false + label: + description: + - "This option allow the user to create a LUKS2 format container + with label support, respectively to identify the container by + label on later usages." + - "Will only be used on container creation, or when I(device) is + not specified." + - "This cannot be specified if I(type) is set to C(luks1)." + type: str + version_added: '1.0.0' + uuid: + description: + - "With this option user can identify the LUKS container by UUID." + - "Will only be used when I(device) and I(label) are not specified." + type: str + version_added: '1.0.0' + type: + description: + - "This option allow the user explicit define the format of LUKS + container that wants to work with. Options are C(luks1) or C(luks2)" + type: str + choices: [luks1, luks2] + version_added: '1.0.0' + cipher: + description: + - "This option allows the user to define the cipher specification + string for the LUKS container." + - "Will only be used on container creation." + - "For pre-2.6.10 kernels, use C(aes-plain) as they do not understand + the new cipher spec strings. To use ESSIV, use C(aes-cbc-essiv:sha256)." + type: str + version_added: '1.1.0' + hash: + description: + - "This option allows the user to specify the hash function used in LUKS + key setup scheme and volume key digest." + - "Will only be used on container creation." + type: str + version_added: '1.1.0' + pbkdf: + description: + - This option allows the user to configure the Password-Based Key Derivation + Function (PBKDF) used. + - Will only be used on container creation, and when adding keys to an existing + container. + type: dict + version_added: '1.4.0' + suboptions: + iteration_time: + description: + - Specify the iteration time used for the PBKDF. + - Note that this is in B(seconds), not in milliseconds as on the + command line. + - Mutually exclusive with I(iteration_count). + type: float + iteration_count: + description: + - Specify the iteration count used for the PBKDF. + - Mutually exclusive with I(iteration_time). + type: int + algorithm: + description: + - The algorithm to use. + - Only available for the LUKS 2 format. + choices: + - argon2i + - argon2id + - pbkdf2 + type: str + memory: + description: + - The memory cost limit in kilobytes for the PBKDF. + - This is not used for PBKDF2, but only for the Argon PBKDFs. + type: int + parallel: + description: + - The parallel cost for the PBKDF. This is the number of threads that + run in parallel. + - This is not used for PBKDF2, but only for the Argon PBKDFs. + type: int + sector_size: + description: + - "This option allows the user to specify the sector size (in bytes) used for LUKS2 containers." + - "Will only be used on container creation." + type: int + version_added: '1.5.0' + perf_same_cpu_crypt: + description: + - "Allows the user to perform encryption using the same CPU that IO was submitted on." + - "The default is to use an unbound workqueue so that encryption work is automatically balanced between available CPUs." + - "Will only be used when opening containers." + type: bool + default: false + version_added: '2.3.0' + perf_submit_from_crypt_cpus: + description: + - "Allows the user to disable offloading writes to a separate thread after encryption." + - "There are some situations where offloading block write IO operations from the encryption threads + to a single thread degrades performance significantly." + - "The default is to offload block write IO operations to the same thread." + - "Will only be used when opening containers." + type: bool + default: false + version_added: '2.3.0' + perf_no_read_workqueue: + description: + - "Allows the user to bypass dm-crypt internal workqueue and process read requests synchronously." + - "Will only be used when opening containers." + type: bool + default: false + version_added: '2.3.0' + perf_no_write_workqueue: + description: + - "Allows the user to bypass dm-crypt internal workqueue and process write requests synchronously." + - "Will only be used when opening containers." + type: bool + default: false + version_added: '2.3.0' + persistent: + description: + - "Allows the user to store options into container's metadata persistently and automatically use them next time. + Only I(perf_same_cpu_crypt), I(perf_submit_from_crypt_cpus), I(perf_no_read_workqueue), and I(perf_no_write_workqueue) + can be stored persistently." + - "Will only work with LUKS2 containers." + - "Will only be used when opening containers." + type: bool + default: false + version_added: '2.3.0' + +requirements: + - "cryptsetup" + - "wipefs (when I(state) is C(absent))" + - "lsblk" + - "blkid (when I(label) or I(uuid) options are used)" + +author: Jan Pokorny (@japokorn) +''' + +EXAMPLES = ''' + +- name: Create LUKS container (remains unchanged if it already exists) + community.crypto.luks_device: + device: "/dev/loop0" + state: "present" + keyfile: "/vault/keyfile" + +- name: Create LUKS container with a passphrase + community.crypto.luks_device: + device: "/dev/loop0" + state: "present" + passphrase: "foo" + +- name: Create LUKS container with specific encryption + community.crypto.luks_device: + device: "/dev/loop0" + state: "present" + cipher: "aes" + hash: "sha256" + +- name: (Create and) open the LUKS container; name it "mycrypt" + community.crypto.luks_device: + device: "/dev/loop0" + state: "opened" + name: "mycrypt" + keyfile: "/vault/keyfile" + +- name: Close the existing LUKS container "mycrypt" + community.crypto.luks_device: + state: "closed" + name: "mycrypt" + +- name: Make sure LUKS container exists and is closed + community.crypto.luks_device: + device: "/dev/loop0" + state: "closed" + keyfile: "/vault/keyfile" + +- name: Create container if it does not exist and add new key to it + community.crypto.luks_device: + device: "/dev/loop0" + state: "present" + keyfile: "/vault/keyfile" + new_keyfile: "/vault/keyfile2" + +- name: Add new key to the LUKS container (container has to exist) + community.crypto.luks_device: + device: "/dev/loop0" + keyfile: "/vault/keyfile" + new_keyfile: "/vault/keyfile2" + +- name: Add new passphrase to the LUKS container + community.crypto.luks_device: + device: "/dev/loop0" + keyfile: "/vault/keyfile" + new_passphrase: "foo" + +- name: Remove existing keyfile from the LUKS container + community.crypto.luks_device: + device: "/dev/loop0" + remove_keyfile: "/vault/keyfile2" + +- name: Remove existing passphrase from the LUKS container + community.crypto.luks_device: + device: "/dev/loop0" + remove_passphrase: "foo" + +- name: Completely remove the LUKS container and its contents + community.crypto.luks_device: + device: "/dev/loop0" + state: "absent" + +- name: Create a container with label + community.crypto.luks_device: + device: "/dev/loop0" + state: "present" + keyfile: "/vault/keyfile" + label: personalLabelName + +- name: Open the LUKS container based on label without device; name it "mycrypt" + community.crypto.luks_device: + label: "personalLabelName" + state: "opened" + name: "mycrypt" + keyfile: "/vault/keyfile" + +- name: Close container based on UUID + community.crypto.luks_device: + uuid: 03ecd578-fad4-4e6c-9348-842e3e8fa340 + state: "closed" + name: "mycrypt" + +- name: Create a container using luks2 format + community.crypto.luks_device: + device: "/dev/loop0" + state: "present" + keyfile: "/vault/keyfile" + type: luks2 +''' + +RETURN = ''' +name: + description: + When I(state=opened) returns (generated or given) name + of LUKS container. Returns None if no name is supplied. + returned: success + type: str + sample: "luks-c1da9a58-2fde-4256-9d9f-6ab008b4dd1b" +''' + +import os +import re +import stat + +from ansible.module_utils.basic import AnsibleModule + +RETURN_CODE = 0 +STDOUT = 1 +STDERR = 2 + +# used to get <luks-name> out of lsblk output in format 'crypt <luks-name>' +# regex takes care of any possible blank characters +LUKS_NAME_REGEX = re.compile(r'^crypt\s+([^\s]*)\s*$') +# used to get </luks/device> out of lsblk output +# in format 'device: </luks/device>' +LUKS_DEVICE_REGEX = re.compile(r'\s*device:\s+([^\s]*)\s*') + + +# See https://gitlab.com/cryptsetup/cryptsetup/-/wikis/LUKS-standard/on-disk-format.pdf +LUKS_HEADER = b'LUKS\xba\xbe' +LUKS_HEADER_L = 6 +# See https://gitlab.com/cryptsetup/LUKS2-docs/-/blob/master/luks2_doc_wip.pdf +LUKS2_HEADER_OFFSETS = [0x4000, 0x8000, 0x10000, 0x20000, 0x40000, 0x80000, 0x100000, 0x200000, 0x400000] +LUKS2_HEADER2 = b'SKUL\xba\xbe' + + +def wipe_luks_headers(device): + wipe_offsets = [] + with open(device, 'rb') as f: + # f.seek(0) + data = f.read(LUKS_HEADER_L) + if data == LUKS_HEADER: + wipe_offsets.append(0) + for offset in LUKS2_HEADER_OFFSETS: + f.seek(offset) + data = f.read(LUKS_HEADER_L) + if data == LUKS2_HEADER2: + wipe_offsets.append(offset) + + if wipe_offsets: + with open(device, 'wb') as f: + for offset in wipe_offsets: + f.seek(offset) + f.write(b'\x00\x00\x00\x00\x00\x00') + + +class Handler(object): + + def __init__(self, module): + self._module = module + self._lsblk_bin = self._module.get_bin_path('lsblk', True) + + def _run_command(self, command, data=None): + return self._module.run_command(command, data=data) + + def get_device_by_uuid(self, uuid): + ''' Returns the device that holds UUID passed by user + ''' + self._blkid_bin = self._module.get_bin_path('blkid', True) + uuid = self._module.params['uuid'] + if uuid is None: + return None + result = self._run_command([self._blkid_bin, '--uuid', uuid]) + if result[RETURN_CODE] != 0: + return None + return result[STDOUT].strip() + + def get_device_by_label(self, label): + ''' Returns the device that holds label passed by user + ''' + self._blkid_bin = self._module.get_bin_path('blkid', True) + label = self._module.params['label'] + if label is None: + return None + result = self._run_command([self._blkid_bin, '--label', label]) + if result[RETURN_CODE] != 0: + return None + return result[STDOUT].strip() + + def generate_luks_name(self, device): + ''' Generate name for luks based on device UUID ('luks-<UUID>'). + Raises ValueError when obtaining of UUID fails. + ''' + result = self._run_command([self._lsblk_bin, '-n', device, '-o', 'UUID']) + + if result[RETURN_CODE] != 0: + raise ValueError('Error while generating LUKS name for %s: %s' + % (device, result[STDERR])) + dev_uuid = result[STDOUT].strip() + return 'luks-%s' % dev_uuid + + +class CryptHandler(Handler): + + def __init__(self, module): + super(CryptHandler, self).__init__(module) + self._cryptsetup_bin = self._module.get_bin_path('cryptsetup', True) + + def get_container_name_by_device(self, device): + ''' obtain LUKS container name based on the device where it is located + return None if not found + raise ValueError if lsblk command fails + ''' + result = self._run_command([self._lsblk_bin, device, '-nlo', 'type,name']) + if result[RETURN_CODE] != 0: + raise ValueError('Error while obtaining LUKS name for %s: %s' + % (device, result[STDERR])) + + for line in result[STDOUT].splitlines(False): + m = LUKS_NAME_REGEX.match(line) + if m: + return m.group(1) + return None + + def get_container_device_by_name(self, name): + ''' obtain device name based on the LUKS container name + return None if not found + raise ValueError if lsblk command fails + ''' + # apparently each device can have only one LUKS container on it + result = self._run_command([self._cryptsetup_bin, 'status', name]) + if result[RETURN_CODE] != 0: + return None + + m = LUKS_DEVICE_REGEX.search(result[STDOUT]) + device = m.group(1) + return device + + def is_luks(self, device): + ''' check if the LUKS container does exist + ''' + result = self._run_command([self._cryptsetup_bin, 'isLuks', device]) + return result[RETURN_CODE] == 0 + + def _add_pbkdf_options(self, options, pbkdf): + if pbkdf['iteration_time'] is not None: + options.extend(['--iter-time', str(int(pbkdf['iteration_time'] * 1000))]) + if pbkdf['iteration_count'] is not None: + options.extend(['--pbkdf-force-iterations', str(pbkdf['iteration_count'])]) + if pbkdf['algorithm'] is not None: + options.extend(['--pbkdf', pbkdf['algorithm']]) + if pbkdf['memory'] is not None: + options.extend(['--pbkdf-memory', str(pbkdf['memory'])]) + if pbkdf['parallel'] is not None: + options.extend(['--pbkdf-parallel', str(pbkdf['parallel'])]) + + def run_luks_create(self, device, keyfile, passphrase, keysize, cipher, hash_, sector_size, pbkdf): + # create a new luks container; use batch mode to auto confirm + luks_type = self._module.params['type'] + label = self._module.params['label'] + + options = [] + if keysize is not None: + options.append('--key-size=' + str(keysize)) + if label is not None: + options.extend(['--label', label]) + luks_type = 'luks2' + if luks_type is not None: + options.extend(['--type', luks_type]) + if cipher is not None: + options.extend(['--cipher', cipher]) + if hash_ is not None: + options.extend(['--hash', hash_]) + if pbkdf is not None: + self._add_pbkdf_options(options, pbkdf) + if sector_size is not None: + options.extend(['--sector-size', str(sector_size)]) + + args = [self._cryptsetup_bin, 'luksFormat'] + args.extend(options) + args.extend(['-q', device]) + if keyfile: + args.append(keyfile) + + result = self._run_command(args, data=passphrase) + if result[RETURN_CODE] != 0: + raise ValueError('Error while creating LUKS on %s: %s' + % (device, result[STDERR])) + + def run_luks_open(self, device, keyfile, passphrase, perf_same_cpu_crypt, perf_submit_from_crypt_cpus, + perf_no_read_workqueue, perf_no_write_workqueue, persistent, name): + args = [self._cryptsetup_bin] + if keyfile: + args.extend(['--key-file', keyfile]) + if perf_same_cpu_crypt: + args.extend(['--perf-same_cpu_crypt']) + if perf_submit_from_crypt_cpus: + args.extend(['--perf-submit_from_crypt_cpus']) + if perf_no_read_workqueue: + args.extend(['--perf-no_read_workqueue']) + if perf_no_write_workqueue: + args.extend(['--perf-no_write_workqueue']) + if persistent: + args.extend(['--persistent']) + args.extend(['open', '--type', 'luks', device, name]) + + result = self._run_command(args, data=passphrase) + if result[RETURN_CODE] != 0: + raise ValueError('Error while opening LUKS container on %s: %s' + % (device, result[STDERR])) + + def run_luks_close(self, name): + result = self._run_command([self._cryptsetup_bin, 'close', name]) + if result[RETURN_CODE] != 0: + raise ValueError('Error while closing LUKS container %s' % (name)) + + def run_luks_remove(self, device): + wipefs_bin = self._module.get_bin_path('wipefs', True) + + name = self.get_container_name_by_device(device) + if name is not None: + self.run_luks_close(name) + result = self._run_command([wipefs_bin, '--all', device]) + if result[RETURN_CODE] != 0: + raise ValueError('Error while wiping LUKS container signatures for %s: %s' + % (device, result[STDERR])) + + # For LUKS2, sometimes both `cryptsetup erase` and `wipefs` do **not** + # erase all LUKS signatures (they seem to miss the second header). That's + # why we do it ourselves here. + try: + wipe_luks_headers(device) + except Exception as exc: + raise ValueError('Error while wiping LUKS container signatures for %s: %s' % (device, exc)) + + def run_luks_add_key(self, device, keyfile, passphrase, new_keyfile, + new_passphrase, pbkdf): + ''' Add new key from a keyfile or passphrase to given 'device'; + authentication done using 'keyfile' or 'passphrase'. + Raises ValueError when command fails. + ''' + data = [] + args = [self._cryptsetup_bin, 'luksAddKey', device] + if pbkdf is not None: + self._add_pbkdf_options(args, pbkdf) + + if keyfile: + args.extend(['--key-file', keyfile]) + else: + data.append(passphrase) + + if new_keyfile: + args.append(new_keyfile) + else: + data.extend([new_passphrase, new_passphrase]) + + result = self._run_command(args, data='\n'.join(data) or None) + if result[RETURN_CODE] != 0: + raise ValueError('Error while adding new LUKS keyslot to %s: %s' + % (device, result[STDERR])) + + def run_luks_remove_key(self, device, keyfile, passphrase, + force_remove_last_key=False): + ''' Remove key from given device + Raises ValueError when command fails + ''' + if not force_remove_last_key: + result = self._run_command([self._cryptsetup_bin, 'luksDump', device]) + if result[RETURN_CODE] != 0: + raise ValueError('Error while dumping LUKS header from %s' + % (device, )) + keyslot_count = 0 + keyslot_area = False + keyslot_re = re.compile(r'^Key Slot [0-9]+: ENABLED') + for line in result[STDOUT].splitlines(): + if line.startswith('Keyslots:'): + keyslot_area = True + elif line.startswith(' '): + # LUKS2 header dumps use human-readable indented output. + # Thus we have to look out for 'Keyslots:' and count the + # number of indented keyslot numbers. + if keyslot_area and line[2] in '0123456789': + keyslot_count += 1 + elif line.startswith('\t'): + pass + elif keyslot_re.match(line): + # LUKS1 header dumps have one line per keyslot with ENABLED + # or DISABLED in them. We count such lines with ENABLED. + keyslot_count += 1 + else: + keyslot_area = False + if keyslot_count < 2: + self._module.fail_json(msg="LUKS device %s has less than two active keyslots. " + "To be able to remove a key, please set " + "`force_remove_last_key` to `true`." % device) + + args = [self._cryptsetup_bin, 'luksRemoveKey', device, '-q'] + if keyfile: + args.extend(['--key-file', keyfile]) + result = self._run_command(args, data=passphrase) + if result[RETURN_CODE] != 0: + raise ValueError('Error while removing LUKS key from %s: %s' + % (device, result[STDERR])) + + def luks_test_key(self, device, keyfile, passphrase): + ''' Check whether the keyfile or passphrase works. + Raises ValueError when command fails. + ''' + data = None + args = [self._cryptsetup_bin, 'luksOpen', '--test-passphrase', device] + + if keyfile: + args.extend(['--key-file', keyfile]) + else: + data = passphrase + + result = self._run_command(args, data=data) + if result[RETURN_CODE] == 0: + return True + for output in (STDOUT, STDERR): + if 'No key available with this passphrase' in result[output]: + return False + + raise ValueError('Error while testing whether keyslot exists on %s: %s' + % (device, result[STDERR])) + + +class ConditionsHandler(Handler): + + def __init__(self, module, crypthandler): + super(ConditionsHandler, self).__init__(module) + self._crypthandler = crypthandler + self.device = self.get_device_name() + + def get_device_name(self): + device = self._module.params.get('device') + label = self._module.params.get('label') + uuid = self._module.params.get('uuid') + name = self._module.params.get('name') + + if device is None and label is not None: + device = self.get_device_by_label(label) + elif device is None and uuid is not None: + device = self.get_device_by_uuid(uuid) + elif device is None and name is not None: + device = self._crypthandler.get_container_device_by_name(name) + + return device + + def luks_create(self): + return (self.device is not None and + (self._module.params['keyfile'] is not None or + self._module.params['passphrase'] is not None) and + self._module.params['state'] in ('present', + 'opened', + 'closed') and + not self._crypthandler.is_luks(self.device)) + + def opened_luks_name(self): + ''' If luks is already opened, return its name. + If 'name' parameter is specified and differs + from obtained value, fail. + Return None otherwise + ''' + if self._module.params['state'] != 'opened': + return None + + # try to obtain luks name - it may be already opened + name = self._crypthandler.get_container_name_by_device(self.device) + + if name is None: + # container is not open + return None + + if self._module.params['name'] is None: + # container is already opened + return name + + if name != self._module.params['name']: + # the container is already open but with different name: + # suspicious. back off + self._module.fail_json(msg="LUKS container is already opened " + "under different name '%s'." % name) + + # container is opened and the names match + return name + + def luks_open(self): + if ((self._module.params['keyfile'] is None and + self._module.params['passphrase'] is None) or + self.device is None or + self._module.params['state'] != 'opened'): + # conditions for open not fulfilled + return False + + name = self.opened_luks_name() + + if name is None: + return True + return False + + def luks_close(self): + if ((self._module.params['name'] is None and self.device is None) or + self._module.params['state'] != 'closed'): + # conditions for close not fulfilled + return False + + if self.device is not None: + name = self._crypthandler.get_container_name_by_device(self.device) + # successfully getting name based on device means that luks is open + luks_is_open = name is not None + + if self._module.params['name'] is not None: + self.device = self._crypthandler.get_container_device_by_name( + self._module.params['name']) + # successfully getting device based on name means that luks is open + luks_is_open = self.device is not None + + return luks_is_open + + def luks_add_key(self): + if (self.device is None or + (self._module.params['keyfile'] is None and + self._module.params['passphrase'] is None) or + (self._module.params['new_keyfile'] is None and + self._module.params['new_passphrase'] is None)): + # conditions for adding a key not fulfilled + return False + + if self._module.params['state'] == 'absent': + self._module.fail_json(msg="Contradiction in setup: Asking to " + "add a key to absent LUKS.") + + return not self._crypthandler.luks_test_key(self.device, self._module.params['new_keyfile'], self._module.params['new_passphrase']) + + def luks_remove_key(self): + if (self.device is None or + (self._module.params['remove_keyfile'] is None and + self._module.params['remove_passphrase'] is None)): + # conditions for removing a key not fulfilled + return False + + if self._module.params['state'] == 'absent': + self._module.fail_json(msg="Contradiction in setup: Asking to " + "remove a key from absent LUKS.") + + return self._crypthandler.luks_test_key(self.device, self._module.params['remove_keyfile'], self._module.params['remove_passphrase']) + + def luks_remove(self): + return (self.device is not None and + self._module.params['state'] == 'absent' and + self._crypthandler.is_luks(self.device)) + + +def run_module(): + # available arguments/parameters that a user can pass + module_args = dict( + state=dict(type='str', default='present', choices=['present', 'absent', 'opened', 'closed']), + device=dict(type='str'), + name=dict(type='str'), + keyfile=dict(type='path'), + new_keyfile=dict(type='path'), + remove_keyfile=dict(type='path'), + passphrase=dict(type='str', no_log=True), + new_passphrase=dict(type='str', no_log=True), + remove_passphrase=dict(type='str', no_log=True), + force_remove_last_key=dict(type='bool', default=False), + keysize=dict(type='int'), + label=dict(type='str'), + uuid=dict(type='str'), + type=dict(type='str', choices=['luks1', 'luks2']), + cipher=dict(type='str'), + hash=dict(type='str'), + pbkdf=dict( + type='dict', + options=dict( + iteration_time=dict(type='float'), + iteration_count=dict(type='int'), + algorithm=dict(type='str', choices=['argon2i', 'argon2id', 'pbkdf2']), + memory=dict(type='int'), + parallel=dict(type='int'), + ), + mutually_exclusive=[('iteration_time', 'iteration_count')], + ), + sector_size=dict(type='int'), + perf_same_cpu_crypt=dict(type='bool', default=False), + perf_submit_from_crypt_cpus=dict(type='bool', default=False), + perf_no_read_workqueue=dict(type='bool', default=False), + perf_no_write_workqueue=dict(type='bool', default=False), + persistent=dict(type='bool', default=False), + ) + + mutually_exclusive = [ + ('keyfile', 'passphrase'), + ('new_keyfile', 'new_passphrase'), + ('remove_keyfile', 'remove_passphrase') + ] + + # seed the result dict in the object + result = dict( + changed=False, + name=None + ) + + module = AnsibleModule(argument_spec=module_args, + supports_check_mode=True, + mutually_exclusive=mutually_exclusive) + module.run_command_environ_update = dict(LANG='C', LC_ALL='C', LC_MESSAGES='C', LC_CTYPE='C') + + if module.params['device'] is not None: + try: + statinfo = os.stat(module.params['device']) + mode = statinfo.st_mode + if not stat.S_ISBLK(mode) and not stat.S_ISCHR(mode): + raise Exception('{0} is not a device'.format(module.params['device'])) + except Exception as e: + module.fail_json(msg=str(e)) + + crypt = CryptHandler(module) + conditions = ConditionsHandler(module, crypt) + + # conditions not allowed to run + if module.params['label'] is not None and module.params['type'] == 'luks1': + module.fail_json(msg='You cannot combine type luks1 with the label option.') + + # The conditions are in order to allow more operations in one run. + # (e.g. create luks and add a key to it) + + # luks create + if conditions.luks_create(): + if not module.check_mode: + try: + crypt.run_luks_create(conditions.device, + module.params['keyfile'], + module.params['passphrase'], + module.params['keysize'], + module.params['cipher'], + module.params['hash'], + module.params['sector_size'], + module.params['pbkdf'], + ) + except ValueError as e: + module.fail_json(msg="luks_device error: %s" % e) + result['changed'] = True + if module.check_mode: + module.exit_json(**result) + + # luks open + + name = conditions.opened_luks_name() + if name is not None: + result['name'] = name + + if conditions.luks_open(): + name = module.params['name'] + if name is None: + try: + name = crypt.generate_luks_name(conditions.device) + except ValueError as e: + module.fail_json(msg="luks_device error: %s" % e) + if not module.check_mode: + try: + crypt.run_luks_open(conditions.device, + module.params['keyfile'], + module.params['passphrase'], + module.params['perf_same_cpu_crypt'], + module.params['perf_submit_from_crypt_cpus'], + module.params['perf_no_read_workqueue'], + module.params['perf_no_write_workqueue'], + module.params['persistent'], + name) + except ValueError as e: + module.fail_json(msg="luks_device error: %s" % e) + result['name'] = name + result['changed'] = True + if module.check_mode: + module.exit_json(**result) + + # luks close + if conditions.luks_close(): + if conditions.device is not None: + try: + name = crypt.get_container_name_by_device( + conditions.device) + except ValueError as e: + module.fail_json(msg="luks_device error: %s" % e) + else: + name = module.params['name'] + if not module.check_mode: + try: + crypt.run_luks_close(name) + except ValueError as e: + module.fail_json(msg="luks_device error: %s" % e) + result['name'] = name + result['changed'] = True + if module.check_mode: + module.exit_json(**result) + + # luks add key + if conditions.luks_add_key(): + if not module.check_mode: + try: + crypt.run_luks_add_key(conditions.device, + module.params['keyfile'], + module.params['passphrase'], + module.params['new_keyfile'], + module.params['new_passphrase'], + module.params['pbkdf']) + except ValueError as e: + module.fail_json(msg="luks_device error: %s" % e) + result['changed'] = True + if module.check_mode: + module.exit_json(**result) + + # luks remove key + if conditions.luks_remove_key(): + if not module.check_mode: + try: + last_key = module.params['force_remove_last_key'] + crypt.run_luks_remove_key(conditions.device, + module.params['remove_keyfile'], + module.params['remove_passphrase'], + force_remove_last_key=last_key) + except ValueError as e: + module.fail_json(msg="luks_device error: %s" % e) + result['changed'] = True + if module.check_mode: + module.exit_json(**result) + + # luks remove + if conditions.luks_remove(): + if not module.check_mode: + try: + crypt.run_luks_remove(conditions.device) + except ValueError as e: + module.fail_json(msg="luks_device error: %s" % e) + result['changed'] = True + if module.check_mode: + module.exit_json(**result) + + # Success - return result + module.exit_json(**result) + + +def main(): + run_module() + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/crypto/plugins/modules/openssh_cert.py b/ansible_collections/community/crypto/plugins/modules/openssh_cert.py new file mode 100644 index 00000000..8f428107 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/modules/openssh_cert.py @@ -0,0 +1,578 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2018, David Kainz <dkainz@mgit.at> <dave.jokain@gmx.at> +# 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: openssh_cert +author: "David Kainz (@lolcube)" +short_description: Generate OpenSSH host or user certificates. +description: + - Generate and regenerate OpenSSH host or user certificates. +requirements: + - "ssh-keygen" +extends_documentation_fragment: + - ansible.builtin.files + - community.crypto.attributes + - community.crypto.attributes.files +attributes: + check_mode: + support: full + diff_mode: + support: full + safe_file_operations: + support: full +options: + state: + description: + - Whether the host or user certificate should exist or not, taking action if the state is different + from what is stated. + type: str + default: "present" + choices: [ 'present', 'absent' ] + type: + description: + - Whether the module should generate a host or a user certificate. + - Required if I(state) is C(present). + type: str + choices: ['host', 'user'] + force: + description: + - Should the certificate be regenerated even if it already exists and is valid. + - Equivalent to I(regenerate=always). + type: bool + default: false + path: + description: + - Path of the file containing the certificate. + type: path + required: true + regenerate: + description: + - When C(never) the task will fail if a certificate already exists at I(path) and is unreadable + otherwise a new certificate will only be generated if there is no existing certificate. + - When C(fail) the task will fail if a certificate already exists at I(path) and does not + match the module's options. + - When C(partial_idempotence) an existing certificate will be regenerated based on + I(serial), I(signature_algorithm), I(type), I(valid_from), I(valid_to), I(valid_at), and I(principals). + I(valid_from) and I(valid_to) can be excluded by I(ignore_timestamps=true). + - When C(full_idempotence) I(identifier), I(options), I(public_key), and I(signing_key) + are also considered when compared against an existing certificate. + - C(always) is equivalent to I(force=true). + type: str + choices: + - never + - fail + - partial_idempotence + - full_idempotence + - always + default: partial_idempotence + version_added: 1.8.0 + signature_algorithm: + description: + - As of OpenSSH 8.2 the SHA-1 signature algorithm for RSA keys has been disabled and C(ssh) will refuse + host certificates signed with the SHA-1 algorithm. OpenSSH 8.1 made C(rsa-sha2-512) the default algorithm + when acting as a CA and signing certificates with a RSA key. However, for OpenSSH versions less than 8.1 + the SHA-2 signature algorithms, C(rsa-sha2-256) or C(rsa-sha2-512), must be specified using this option + if compatibility with newer C(ssh) clients is required. Conversely if hosts using OpenSSH version 8.2 + or greater must remain compatible with C(ssh) clients using OpenSSH less than 7.2, then C(ssh-rsa) + can be used when generating host certificates (a corresponding change to the sshd_config to add C(ssh-rsa) + to the C(CASignatureAlgorithms) keyword is also required). + - Using any value for this option with a non-RSA I(signing_key) will cause this module to fail. + - "Note: OpenSSH versions prior to 7.2 do not support SHA-2 signature algorithms for RSA keys and OpenSSH + versions prior to 7.3 do not support SHA-2 signature algorithms for certificates." + - See U(https://www.openssh.com/txt/release-8.2) for more information. + type: str + choices: + - ssh-rsa + - rsa-sha2-256 + - rsa-sha2-512 + version_added: 1.10.0 + signing_key: + description: + - The path to the private openssh key that is used for signing the public key in order to generate the certificate. + - If the private key is on a PKCS#11 token (I(pkcs11_provider)), set this to the path to the public key instead. + - Required if I(state) is C(present). + type: path + pkcs11_provider: + description: + - To use a signing key that resides on a PKCS#11 token, set this to the name (or full path) of the shared library to use with the token. + Usually C(libpkcs11.so). + - If this is set, I(signing_key) needs to point to a file containing the public key of the CA. + type: str + version_added: 1.1.0 + use_agent: + description: + - Should the ssh-keygen use a CA key residing in a ssh-agent. + type: bool + default: false + version_added: 1.3.0 + public_key: + description: + - The path to the public key that will be signed with the signing key in order to generate the certificate. + - Required if I(state) is C(present). + type: path + valid_from: + description: + - "The point in time the certificate is valid from. Time can be specified either as relative time or as absolute timestamp. + Time will always be interpreted as UTC. Valid formats are: C([+-]timespec | YYYY-MM-DD | YYYY-MM-DDTHH:MM:SS | YYYY-MM-DD HH:MM:SS | always) + where timespec can be an integer + C([w | d | h | m | s]) (for example C(+32w1d2h)). + Note that if using relative time this module is NOT idempotent." + - "The value C(always) is only supported for OpenSSH 7.7 and greater, however, the value C(1970-01-01T00:00:01) + can be used with earlier versions as an equivalent expression." + - "To ignore this value during comparison with an existing certificate set I(ignore_timestamps=true)." + - Required if I(state) is C(present). + type: str + valid_to: + description: + - "The point in time the certificate is valid to. Time can be specified either as relative time or as absolute timestamp. + Time will always be interpreted as UTC. Valid formats are: C([+-]timespec | YYYY-MM-DD | YYYY-MM-DDTHH:MM:SS | YYYY-MM-DD HH:MM:SS | forever) + where timespec can be an integer + C([w | d | h | m | s]) (for example C(+32w1d2h)). + Note that if using relative time this module is NOT idempotent." + - "To ignore this value during comparison with an existing certificate set I(ignore_timestamps=true)." + - Required if I(state) is C(present). + type: str + valid_at: + description: + - "Check if the certificate is valid at a certain point in time. If it is not the certificate will be regenerated. + Time will always be interpreted as UTC. Mainly to be used with relative timespec for I(valid_from) and / or I(valid_to). + Note that if using relative time this module is NOT idempotent." + type: str + ignore_timestamps: + description: + - "Whether the I(valid_from) and I(valid_to) timestamps should be ignored for idempotency checks." + - "However, the values will still be applied to a new certificate if it meets any other necessary conditions for generation/regeneration." + type: bool + default: false + version_added: 2.2.0 + principals: + description: + - "Certificates may be limited to be valid for a set of principal (user/host) names. + By default, generated certificates are valid for all users or hosts." + type: list + elements: str + options: + description: + - "Specify certificate options when signing a key. The option that are valid for user certificates are:" + - "C(clear): Clear all enabled permissions. This is useful for clearing the default set of permissions so permissions may be added individually." + - "C(force-command=command): Forces the execution of command instead of any shell or + command specified by the user when the certificate is used for authentication." + - "C(no-agent-forwarding): Disable ssh-agent forwarding (permitted by default)." + - "C(no-port-forwarding): Disable port forwarding (permitted by default)." + - "C(no-pty): Disable PTY allocation (permitted by default)." + - "C(no-user-rc): Disable execution of C(~/.ssh/rc) by sshd (permitted by default)." + - "C(no-x11-forwarding): Disable X11 forwarding (permitted by default)" + - "C(permit-agent-forwarding): Allows ssh-agent forwarding." + - "C(permit-port-forwarding): Allows port forwarding." + - "C(permit-pty): Allows PTY allocation." + - "C(permit-user-rc): Allows execution of C(~/.ssh/rc) by sshd." + - "C(permit-x11-forwarding): Allows X11 forwarding." + - "C(source-address=address_list): Restrict the source addresses from which the certificate is considered valid. + The C(address_list) is a comma-separated list of one or more address/netmask pairs in CIDR format." + - "At present, no options are valid for host keys." + type: list + elements: str + identifier: + description: + - Specify the key identity when signing a public key. The identifier that is logged by the server when the certificate is used for authentication. + type: str + serial_number: + description: + - "Specify the certificate serial number. + The serial number is logged by the server when the certificate is used for authentication. + The certificate serial number may be used in a KeyRevocationList. + The serial number may be omitted for checks, but must be specified again for a new certificate. + Note: The default value set by ssh-keygen is 0." + type: int +''' + +EXAMPLES = ''' +- name: Generate an OpenSSH user certificate that is valid forever and for all users + community.crypto.openssh_cert: + type: user + signing_key: /path/to/private_key + public_key: /path/to/public_key.pub + path: /path/to/certificate + valid_from: always + valid_to: forever + +# Generate an OpenSSH host certificate that is valid for 32 weeks from now and will be regenerated +# if it is valid for less than 2 weeks from the time the module is being run +- name: Generate an OpenSSH host certificate with valid_from, valid_to and valid_at parameters + community.crypto.openssh_cert: + type: host + signing_key: /path/to/private_key + public_key: /path/to/public_key.pub + path: /path/to/certificate + valid_from: +0s + valid_to: +32w + valid_at: +2w + ignore_timestamps: true + +- name: Generate an OpenSSH host certificate that is valid forever and only for example.com and examplehost + community.crypto.openssh_cert: + type: host + signing_key: /path/to/private_key + public_key: /path/to/public_key.pub + path: /path/to/certificate + valid_from: always + valid_to: forever + principals: + - example.com + - examplehost + +- name: Generate an OpenSSH host Certificate that is valid from 21.1.2001 to 21.1.2019 + community.crypto.openssh_cert: + type: host + signing_key: /path/to/private_key + public_key: /path/to/public_key.pub + path: /path/to/certificate + valid_from: "2001-01-21" + valid_to: "2019-01-21" + +- name: Generate an OpenSSH user Certificate with clear and force-command option + community.crypto.openssh_cert: + type: user + signing_key: /path/to/private_key + public_key: /path/to/public_key.pub + path: /path/to/certificate + valid_from: always + valid_to: forever + options: + - "clear" + - "force-command=/tmp/bla/foo" + +- name: Generate an OpenSSH user certificate using a PKCS#11 token + community.crypto.openssh_cert: + type: user + signing_key: /path/to/ca_public_key.pub + pkcs11_provider: libpkcs11.so + public_key: /path/to/public_key.pub + path: /path/to/certificate + valid_from: always + valid_to: forever + +''' + +RETURN = ''' +type: + description: type of the certificate (host or user) + returned: changed or success + type: str + sample: host +filename: + description: path to the certificate + returned: changed or success + type: str + sample: /tmp/certificate-cert.pub +info: + description: Information about the certificate. Output of C(ssh-keygen -L -f). + returned: change or success + type: list + elements: str + +''' + +import os + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.common.text.converters import to_native + +from ansible_collections.community.crypto.plugins.module_utils.version import LooseVersion + +from ansible_collections.community.crypto.plugins.module_utils.openssh.backends.common import ( + KeygenCommand, + OpensshModule, + PrivateKey, +) + +from ansible_collections.community.crypto.plugins.module_utils.openssh.certificate import ( + OpensshCertificate, + OpensshCertificateTimeParameters, + parse_option_list, +) + + +class Certificate(OpensshModule): + def __init__(self, module): + super(Certificate, self).__init__(module) + self.ssh_keygen = KeygenCommand(self.module) + + self.identifier = self.module.params['identifier'] or "" + self.options = self.module.params['options'] or [] + self.path = self.module.params['path'] + self.pkcs11_provider = self.module.params['pkcs11_provider'] + self.principals = self.module.params['principals'] or [] + self.public_key = self.module.params['public_key'] + self.regenerate = self.module.params['regenerate'] if not self.module.params['force'] else 'always' + self.serial_number = self.module.params['serial_number'] + self.signature_algorithm = self.module.params['signature_algorithm'] + self.signing_key = self.module.params['signing_key'] + self.state = self.module.params['state'] + self.type = self.module.params['type'] + self.use_agent = self.module.params['use_agent'] + self.valid_at = self.module.params['valid_at'] + self.ignore_timestamps = self.module.params['ignore_timestamps'] + + self._check_if_base_dir(self.path) + + if self.state == 'present': + self._validate_parameters() + + self.data = None + self.original_data = None + if self._exists(): + self._load_certificate() + + self.time_parameters = None + if self.state == 'present': + self._set_time_parameters() + + def _validate_parameters(self): + for path in (self.public_key, self.signing_key): + self._check_if_base_dir(path) + + if self.options and self.type == "host": + self.module.fail_json(msg="Options can only be used with user certificates.") + + if self.use_agent: + self._use_agent_available() + + def _use_agent_available(self): + ssh_version = self._get_ssh_version() + if not ssh_version: + self.module.fail_json(msg="Failed to determine ssh version") + elif LooseVersion(ssh_version) < LooseVersion("7.6"): + self.module.fail_json( + msg="Signing with CA key in ssh agent requires ssh 7.6 or newer." + + " Your version is: %s" % ssh_version + ) + + def _exists(self): + return os.path.exists(self.path) + + def _load_certificate(self): + try: + self.original_data = OpensshCertificate.load(self.path) + except (TypeError, ValueError) as e: + if self.regenerate in ('never', 'fail'): + self.module.fail_json(msg="Unable to read existing certificate: %s" % to_native(e)) + self.module.warn("Unable to read existing certificate: %s" % to_native(e)) + + def _set_time_parameters(self): + try: + self.time_parameters = OpensshCertificateTimeParameters( + valid_from=self.module.params['valid_from'], + valid_to=self.module.params['valid_to'], + ) + except ValueError as e: + self.module.fail_json(msg=to_native(e)) + + def _execute(self): + if self.state == 'present': + if self._should_generate(): + self._generate() + self._update_permissions(self.path) + else: + if self._exists(): + self._remove() + + def _should_generate(self): + if self.regenerate == 'never': + return self.original_data is None + elif self.regenerate == 'fail': + if self.original_data and not self._is_fully_valid(): + self.module.fail_json( + msg="Certificate does not match the provided options.", + cert=get_cert_dict(self.original_data) + ) + return self.original_data is None + elif self.regenerate == 'partial_idempotence': + return self.original_data is None or not self._is_partially_valid() + elif self.regenerate == 'full_idempotence': + return self.original_data is None or not self._is_fully_valid() + else: + return True + + def _is_fully_valid(self): + return self._is_partially_valid() and all([ + self._compare_options() if self.original_data.type == 'user' else True, + self.original_data.key_id == self.identifier, + self.original_data.public_key == self._get_key_fingerprint(self.public_key), + self.original_data.signing_key == self._get_key_fingerprint(self.signing_key), + ]) + + def _is_partially_valid(self): + return all([ + set(self.original_data.principals) == set(self.principals), + self.original_data.signature_type == self.signature_algorithm if self.signature_algorithm else True, + self.original_data.serial == self.serial_number if self.serial_number is not None else True, + self.original_data.type == self.type, + self._compare_time_parameters(), + ]) + + def _compare_time_parameters(self): + try: + original_time_parameters = OpensshCertificateTimeParameters( + valid_from=self.original_data.valid_after, + valid_to=self.original_data.valid_before + ) + except ValueError as e: + return self.module.fail_json(msg=to_native(e)) + + if self.ignore_timestamps: + return original_time_parameters.within_range(self.valid_at) + + return all([ + original_time_parameters == self.time_parameters, + original_time_parameters.within_range(self.valid_at) + ]) + + def _compare_options(self): + try: + critical_options, extensions = parse_option_list(self.options) + except ValueError as e: + return self.module.fail_json(msg=to_native(e)) + + return all([ + set(self.original_data.critical_options) == set(critical_options), + set(self.original_data.extensions) == set(extensions) + ]) + + def _get_key_fingerprint(self, path): + private_key_content = self.ssh_keygen.get_private_key(path, check_rc=True)[1] + return PrivateKey.from_string(private_key_content).fingerprint + + @OpensshModule.trigger_change + @OpensshModule.skip_if_check_mode + def _generate(self): + try: + temp_certificate = self._generate_temp_certificate() + self._safe_secure_move([(temp_certificate, self.path)]) + except OSError as e: + self.module.fail_json(msg="Unable to write certificate to %s: %s" % (self.path, to_native(e))) + + try: + self.data = OpensshCertificate.load(self.path) + except (TypeError, ValueError) as e: + self.module.fail_json(msg="Unable to read new certificate: %s" % to_native(e)) + + def _generate_temp_certificate(self): + key_copy = os.path.join(self.module.tmpdir, os.path.basename(self.public_key)) + + try: + self.module.preserved_copy(self.public_key, key_copy) + except OSError as e: + self.module.fail_json(msg="Unable to stage temporary key: %s" % to_native(e)) + self.module.add_cleanup_file(key_copy) + + self.ssh_keygen.generate_certificate( + key_copy, self.identifier, self.options, self.pkcs11_provider, self.principals, self.serial_number, + self.signature_algorithm, self.signing_key, self.type, self.time_parameters, self.use_agent, + environ_update=dict(TZ="UTC"), check_rc=True + ) + + temp_cert = os.path.splitext(key_copy)[0] + '-cert.pub' + self.module.add_cleanup_file(temp_cert) + + return temp_cert + + @OpensshModule.trigger_change + @OpensshModule.skip_if_check_mode + def _remove(self): + try: + os.remove(self.path) + except OSError as e: + self.module.fail_json(msg="Unable to remove existing certificate: %s" % to_native(e)) + + @property + def _result(self): + if self.state != 'present': + return {} + + certificate_info = self.ssh_keygen.get_certificate_info(self.path)[1] + + return { + 'type': self.type, + 'filename': self.path, + 'info': format_cert_info(certificate_info), + } + + @property + def diff(self): + return { + 'before': get_cert_dict(self.original_data), + 'after': get_cert_dict(self.data) + } + + +def format_cert_info(cert_info): + result = [] + string = "" + + for word in cert_info.split(): + if word in ("Type:", "Public", "Signing", "Key", "Serial:", "Valid:", "Principals:", "Critical", "Extensions:"): + result.append(string) + string = word + else: + string += " " + word + result.append(string) + # Drop the certificate path + result.pop(0) + return result + + +def get_cert_dict(data): + if data is None: + return {} + + result = data.to_dict() + result.pop('nonce') + result['signature_algorithm'] = data.signature_type + + return result + + +def main(): + module = AnsibleModule( + argument_spec=dict( + force=dict(type='bool', default=False), + identifier=dict(type='str'), + options=dict(type='list', elements='str'), + path=dict(type='path', required=True), + pkcs11_provider=dict(type='str'), + principals=dict(type='list', elements='str'), + public_key=dict(type='path'), + regenerate=dict( + type='str', + default='partial_idempotence', + choices=['never', 'fail', 'partial_idempotence', 'full_idempotence', 'always'] + ), + signature_algorithm=dict(type='str', choices=['ssh-rsa', 'rsa-sha2-256', 'rsa-sha2-512']), + signing_key=dict(type='path'), + serial_number=dict(type='int'), + state=dict(type='str', default='present', choices=['absent', 'present']), + type=dict(type='str', choices=['host', 'user']), + use_agent=dict(type='bool', default=False), + valid_at=dict(type='str'), + valid_from=dict(type='str'), + valid_to=dict(type='str'), + ignore_timestamps=dict(type='bool', default=False), + ), + supports_check_mode=True, + add_file_common_args=True, + required_if=[('state', 'present', ['type', 'signing_key', 'public_key', 'valid_from', 'valid_to'])], + ) + + Certificate(module).execute() + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/crypto/plugins/modules/openssh_keypair.py b/ansible_collections/community/crypto/plugins/modules/openssh_keypair.py new file mode 100644 index 00000000..274125fc --- /dev/null +++ b/ansible_collections/community/crypto/plugins/modules/openssh_keypair.py @@ -0,0 +1,244 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2018, David Kainz <dkainz@mgit.at> <dave.jokain@gmx.at> +# 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: openssh_keypair +author: "David Kainz (@lolcube)" +short_description: Generate OpenSSH private and public keys +description: + - "This module allows one to (re)generate OpenSSH private and public keys. It uses + ssh-keygen to generate keys. One can generate C(rsa), C(dsa), C(rsa1), C(ed25519) + or C(ecdsa) private keys." +requirements: + - ssh-keygen (if I(backend=openssh)) + - cryptography >= 2.6 (if I(backend=cryptography) and OpenSSH < 7.8 is installed) + - cryptography >= 3.0 (if I(backend=cryptography) and OpenSSH >= 7.8 is installed) +extends_documentation_fragment: + - ansible.builtin.files + - community.crypto.attributes + - community.crypto.attributes.files +attributes: + check_mode: + support: full + diff_mode: + support: full + safe_file_operations: + support: full +options: + state: + description: + - Whether the private and public keys should exist or not, taking action if the state is different from what is stated. + type: str + default: present + choices: [ present, absent ] + size: + description: + - "Specifies the number of bits in the private key to create. For RSA keys, the minimum size is 1024 bits and the default is 4096 bits. + Generally, 2048 bits is considered sufficient. DSA keys must be exactly 1024 bits as specified by FIPS 186-2. + For ECDSA keys, size determines the key length by selecting from one of three elliptic curve sizes: 256, 384 or 521 bits. + Attempting to use bit lengths other than these three values for ECDSA keys will cause this module to fail. + Ed25519 keys have a fixed length and the size will be ignored." + type: int + type: + description: + - "The algorithm used to generate the SSH private key. C(rsa1) is for protocol version 1. + C(rsa1) is deprecated and may not be supported by every version of ssh-keygen." + type: str + default: rsa + choices: ['rsa', 'dsa', 'rsa1', 'ecdsa', 'ed25519'] + force: + description: + - Should the key be regenerated even if it already exists + type: bool + default: false + path: + description: + - Name of the files containing the public and private key. The file containing the public key will have the extension C(.pub). + type: path + required: true + comment: + description: + - Provides a new comment to the public key. + type: str + passphrase: + description: + - Passphrase used to decrypt an existing private key or encrypt a newly generated private key. + - Passphrases are not supported for I(type=rsa1). + - Can only be used when I(backend=cryptography), or when I(backend=auto) and a required C(cryptography) version is installed. + type: str + version_added: 1.7.0 + private_key_format: + description: + - Used when I(backend=cryptography) to select a format for the private key at the provided I(path). + - When set to C(auto) this module will match the key format of the installed OpenSSH version. + - For OpenSSH < 7.8 private keys will be in PKCS1 format except ed25519 keys which will be in OpenSSH format. + - For OpenSSH >= 7.8 all private key types will be in the OpenSSH format. + - Using this option when I(regenerate=partial_idempotence) or I(regenerate=full_idempotence) will cause + a new keypair to be generated if the private key's format does not match the value of I(private_key_format). + This module will not however convert existing private keys between formats. + type: str + default: auto + choices: + - auto + - pkcs1 + - pkcs8 + - ssh + version_added: 1.7.0 + backend: + description: + - Selects between the C(cryptography) library or the OpenSSH binary C(opensshbin). + - C(auto) will default to C(opensshbin) unless the OpenSSH binary is not installed or when using I(passphrase). + type: str + default: auto + choices: + - auto + - cryptography + - opensshbin + version_added: 1.7.0 + regenerate: + description: + - Allows to configure in which situations the module is allowed to regenerate private keys. + The module will always generate a new key if the destination file does not exist. + - By default, the key will be regenerated when it does not match the module's options, + except when the key cannot be read or the passphrase does not match. Please note that + this B(changed) for Ansible 2.10. For Ansible 2.9, the behavior was as if C(full_idempotence) + is specified. + - If set to C(never), the module will fail if the key cannot be read or the passphrase + is not matching, and will never regenerate an existing key. + - If set to C(fail), the module will fail if the key does not correspond to the module's + options. + - If set to C(partial_idempotence), the key will be regenerated if it does not conform to + the module's options. The key is B(not) regenerated if it cannot be read (broken file), + the key is protected by an unknown passphrase, or when they key is not protected by a + passphrase, but a passphrase is specified. + - If set to C(full_idempotence), the key will be regenerated if it does not conform to the + module's options. This is also the case if the key cannot be read (broken file), the key + is protected by an unknown passphrase, or when they key is not protected by a passphrase, + but a passphrase is specified. Make sure you have a B(backup) when using this option! + - If set to C(always), the module will always regenerate the key. This is equivalent to + setting I(force) to C(true). + - Note that adjusting the comment and the permissions can be changed without regeneration. + Therefore, even for C(never), the task can result in changed. + type: str + choices: + - never + - fail + - partial_idempotence + - full_idempotence + - always + default: partial_idempotence + version_added: '1.0.0' +notes: + - In case the ssh key is broken or password protected, the module will fail. + Set the I(force) option to C(true) if you want to regenerate the keypair. + - In the case a custom C(mode), C(group), C(owner), or other file attribute is provided it will be applied to both key files. +''' + +EXAMPLES = ''' +- name: Generate an OpenSSH keypair with the default values (4096 bits, rsa) + community.crypto.openssh_keypair: + path: /tmp/id_ssh_rsa + +- name: Generate an OpenSSH keypair with the default values (4096 bits, rsa) and encrypted private key + community.crypto.openssh_keypair: + path: /tmp/id_ssh_rsa + passphrase: super_secret_password + +- name: Generate an OpenSSH rsa keypair with a different size (2048 bits) + community.crypto.openssh_keypair: + path: /tmp/id_ssh_rsa + size: 2048 + +- name: Force regenerate an OpenSSH keypair if it already exists + community.crypto.openssh_keypair: + path: /tmp/id_ssh_rsa + force: True + +- name: Generate an OpenSSH keypair with a different algorithm (dsa) + community.crypto.openssh_keypair: + path: /tmp/id_ssh_dsa + type: dsa +''' + +RETURN = ''' +size: + description: Size (in bits) of the SSH private key. + returned: changed or success + type: int + sample: 4096 +type: + description: Algorithm used to generate the SSH private key. + returned: changed or success + type: str + sample: rsa +filename: + description: Path to the generated SSH private key file. + returned: changed or success + type: str + sample: /tmp/id_ssh_rsa +fingerprint: + description: The fingerprint of the key. + returned: changed or success + type: str + sample: SHA256:r4YCZxihVjedH2OlfjVGI6Y5xAYtdCwk8VxKyzVyYfM +public_key: + description: The public key of the generated SSH private key. + returned: changed or success + type: str + sample: ssh-rsa AAAAB3Nza(...omitted...)veL4E3Xcw== +comment: + description: The comment of the generated key. + returned: changed or success + type: str + sample: test@comment +''' + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.community.crypto.plugins.module_utils.openssh.backends.keypair_backend import ( + select_backend +) + + +def main(): + + module = AnsibleModule( + argument_spec=dict( + state=dict(type='str', default='present', choices=['present', 'absent']), + size=dict(type='int'), + type=dict(type='str', default='rsa', choices=['rsa', 'dsa', 'rsa1', 'ecdsa', 'ed25519']), + force=dict(type='bool', default=False), + path=dict(type='path', required=True), + comment=dict(type='str'), + regenerate=dict( + type='str', + default='partial_idempotence', + choices=['never', 'fail', 'partial_idempotence', 'full_idempotence', 'always'] + ), + passphrase=dict(type='str', no_log=True), + private_key_format=dict( + type='str', + default='auto', + no_log=False, + choices=['auto', 'pkcs1', 'pkcs8', 'ssh']), + backend=dict(type='str', default='auto', choices=['auto', 'cryptography', 'opensshbin']) + ), + supports_check_mode=True, + add_file_common_args=True, + ) + + keypair = select_backend(module, module.params['backend'])[1] + + keypair.execute() + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/crypto/plugins/modules/openssl_csr.py b/ansible_collections/community/crypto/plugins/modules/openssl_csr.py new file mode 100644 index 00000000..69b663b2 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/modules/openssl_csr.py @@ -0,0 +1,359 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2017, Yanis Guenane <yanis+ansible@guenane.org> +# 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 + + +DOCUMENTATION = r''' +--- +module: openssl_csr +short_description: Generate OpenSSL Certificate Signing Request (CSR) +description: + - "Please note that the module regenerates an existing CSR if it does not match the module's + options, or if it seems to be corrupt. If you are concerned that this could overwrite + your existing CSR, consider using the I(backup) option." +author: + - Yanis Guenane (@Spredzy) + - Felix Fontein (@felixfontein) +extends_documentation_fragment: + - ansible.builtin.files + - community.crypto.attributes + - community.crypto.attributes.files + - community.crypto.module_csr +attributes: + check_mode: + support: full + diff_mode: + support: full + safe_file_operations: + support: full +options: + state: + description: + - Whether the certificate signing request should exist or not, taking action if the state is different from what is stated. + type: str + default: present + choices: [ absent, present ] + force: + description: + - Should the certificate signing request be forced regenerated by this ansible module. + type: bool + default: false + path: + description: + - The name of the file into which the generated OpenSSL certificate signing request will be written. + type: path + required: true + backup: + description: + - Create a backup file including a timestamp so you can get the original + CSR back if you overwrote it with a new one by accident. + type: bool + default: false + return_content: + description: + - If set to C(true), will return the (current or generated) CSR's content as I(csr). + type: bool + default: false + version_added: "1.0.0" + privatekey_content: + version_added: "1.0.0" + name_constraints_permitted: + version_added: 1.1.0 + name_constraints_excluded: + version_added: 1.1.0 + name_constraints_critical: + version_added: 1.1.0 +seealso: + - module: community.crypto.openssl_csr_pipe +''' + +EXAMPLES = r''' +- name: Generate an OpenSSL Certificate Signing Request + community.crypto.openssl_csr: + path: /etc/ssl/csr/www.ansible.com.csr + privatekey_path: /etc/ssl/private/ansible.com.pem + common_name: www.ansible.com + +- name: Generate an OpenSSL Certificate Signing Request with an inline key + community.crypto.openssl_csr: + path: /etc/ssl/csr/www.ansible.com.csr + privatekey_content: "{{ private_key_content }}" + common_name: www.ansible.com + +- name: Generate an OpenSSL Certificate Signing Request with a passphrase protected private key + community.crypto.openssl_csr: + path: /etc/ssl/csr/www.ansible.com.csr + privatekey_path: /etc/ssl/private/ansible.com.pem + privatekey_passphrase: ansible + common_name: www.ansible.com + +- name: Generate an OpenSSL Certificate Signing Request with Subject information + community.crypto.openssl_csr: + path: /etc/ssl/csr/www.ansible.com.csr + privatekey_path: /etc/ssl/private/ansible.com.pem + country_name: FR + organization_name: Ansible + email_address: jdoe@ansible.com + common_name: www.ansible.com + +- name: Generate an OpenSSL Certificate Signing Request with subjectAltName extension + community.crypto.openssl_csr: + path: /etc/ssl/csr/www.ansible.com.csr + privatekey_path: /etc/ssl/private/ansible.com.pem + subject_alt_name: 'DNS:www.ansible.com,DNS:m.ansible.com' + +- name: Generate an OpenSSL CSR with subjectAltName extension with dynamic list + community.crypto.openssl_csr: + path: /etc/ssl/csr/www.ansible.com.csr + privatekey_path: /etc/ssl/private/ansible.com.pem + subject_alt_name: "{{ item.value | map('regex_replace', '^', 'DNS:') | list }}" + with_dict: + dns_server: + - www.ansible.com + - m.ansible.com + +- name: Force regenerate an OpenSSL Certificate Signing Request + community.crypto.openssl_csr: + path: /etc/ssl/csr/www.ansible.com.csr + privatekey_path: /etc/ssl/private/ansible.com.pem + force: true + common_name: www.ansible.com + +- name: Generate an OpenSSL Certificate Signing Request with special key usages + community.crypto.openssl_csr: + path: /etc/ssl/csr/www.ansible.com.csr + privatekey_path: /etc/ssl/private/ansible.com.pem + common_name: www.ansible.com + key_usage: + - digitalSignature + - keyAgreement + extended_key_usage: + - clientAuth + +- name: Generate an OpenSSL Certificate Signing Request with OCSP Must Staple + community.crypto.openssl_csr: + path: /etc/ssl/csr/www.ansible.com.csr + privatekey_path: /etc/ssl/private/ansible.com.pem + common_name: www.ansible.com + ocsp_must_staple: true + +- name: Generate an OpenSSL Certificate Signing Request for WinRM Certificate authentication + community.crypto.openssl_csr: + path: /etc/ssl/csr/winrm.auth.csr + privatekey_path: /etc/ssl/private/winrm.auth.pem + common_name: username + extended_key_usage: + - clientAuth + subject_alt_name: otherName:1.3.6.1.4.1.311.20.2.3;UTF8:username@localhost + +- name: Generate an OpenSSL Certificate Signing Request with a CRL distribution point + community.crypto.openssl_csr: + path: /etc/ssl/csr/www.ansible.com.csr + privatekey_path: /etc/ssl/private/ansible.com.pem + common_name: www.ansible.com + crl_distribution_points: + - full_name: + - "URI:https://ca.example.com/revocations.crl" + crl_issuer: + - "URI:https://ca.example.com/" + reasons: + - key_compromise + - ca_compromise + - cessation_of_operation +''' + +RETURN = r''' +privatekey: + description: + - Path to the TLS/SSL private key the CSR was generated for + - Will be C(none) if the private key has been provided in I(privatekey_content). + returned: changed or success + type: str + sample: /etc/ssl/private/ansible.com.pem +filename: + description: Path to the generated Certificate Signing Request + returned: changed or success + type: str + sample: /etc/ssl/csr/www.ansible.com.csr +subject: + description: A list of the subject tuples attached to the CSR + returned: changed or success + type: list + elements: list + sample: [['CN', 'www.ansible.com'], ['O', 'Ansible']] +subjectAltName: + description: The alternative names this CSR is valid for + returned: changed or success + type: list + elements: str + sample: [ 'DNS:www.ansible.com', 'DNS:m.ansible.com' ] +keyUsage: + description: Purpose for which the public key may be used + returned: changed or success + type: list + elements: str + sample: [ 'digitalSignature', 'keyAgreement' ] +extendedKeyUsage: + description: Additional restriction on the public key purposes + returned: changed or success + type: list + elements: str + sample: [ 'clientAuth' ] +basicConstraints: + description: Indicates if the certificate belongs to a CA + returned: changed or success + type: list + elements: str + sample: ['CA:TRUE', 'pathLenConstraint:0'] +ocsp_must_staple: + description: Indicates whether the certificate has the OCSP + Must Staple feature enabled + returned: changed or success + type: bool + sample: false +name_constraints_permitted: + description: List of permitted subtrees to sign certificates for. + returned: changed or success + type: list + elements: str + sample: ['email:.somedomain.com'] + version_added: 1.1.0 +name_constraints_excluded: + description: List of excluded subtrees the CA cannot sign certificates for. + returned: changed or success + type: list + elements: str + sample: ['email:.com'] + version_added: 1.1.0 +backup_file: + description: Name of backup file created. + returned: changed and if I(backup) is C(true) + type: str + sample: /path/to/www.ansible.com.csr.2019-03-09@11:22~ +csr: + description: The (current or generated) CSR's content. + returned: if I(state) is C(present) and I(return_content) is C(true) + type: str + version_added: "1.0.0" +''' + +import os + +from ansible.module_utils.common.text.converters import to_native + +from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.csr import ( + select_backend, + get_csr_argument_spec, +) + +from ansible_collections.community.crypto.plugins.module_utils.io import ( + load_file_if_exists, + write_file, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import ( + OpenSSLObjectError, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.support import ( + OpenSSLObject, +) + + +class CertificateSigningRequestModule(OpenSSLObject): + + def __init__(self, module, module_backend): + super(CertificateSigningRequestModule, self).__init__( + module.params['path'], + module.params['state'], + module.params['force'], + module.check_mode + ) + self.module_backend = module_backend + self.return_content = module.params['return_content'] + + self.backup = module.params['backup'] + self.backup_file = None + + self.module_backend.set_existing(load_file_if_exists(self.path, module)) + + def generate(self, module): + '''Generate the certificate signing request.''' + if self.force or self.module_backend.needs_regeneration(): + if not self.check_mode: + self.module_backend.generate_csr() + result = self.module_backend.get_csr_data() + if self.backup: + self.backup_file = module.backup_local(self.path) + write_file(module, result) + self.changed = True + + file_args = module.load_file_common_arguments(module.params) + if module.check_file_absent_if_check_mode(file_args['path']): + self.changed = True + else: + self.changed = module.set_fs_attributes_if_different(file_args, self.changed) + + def remove(self, module): + self.module_backend.set_existing(None) + if self.backup and not self.check_mode: + self.backup_file = module.backup_local(self.path) + super(CertificateSigningRequestModule, self).remove(module) + + def dump(self): + '''Serialize the object into a dictionary.''' + result = self.module_backend.dump(include_csr=self.return_content) + result.update({ + 'filename': self.path, + 'changed': self.changed, + }) + if self.backup_file: + result['backup_file'] = self.backup_file + return result + + +def main(): + argument_spec = get_csr_argument_spec() + argument_spec.argument_spec.update(dict( + state=dict(type='str', default='present', choices=['absent', 'present']), + force=dict(type='bool', default=False), + path=dict(type='path', required=True), + backup=dict(type='bool', default=False), + return_content=dict(type='bool', default=False), + )) + argument_spec.required_if.extend([('state', 'present', rof, True) for rof in argument_spec.required_one_of]) + argument_spec.required_one_of = [] + module = argument_spec.create_ansible_module( + add_file_common_args=True, + supports_check_mode=True, + ) + + base_dir = os.path.dirname(module.params['path']) or '.' + if not os.path.isdir(base_dir): + module.fail_json(name=base_dir, msg='The directory %s does not exist or the file is not a directory' % base_dir) + + try: + backend = module.params['select_crypto_backend'] + backend, module_backend = select_backend(module, backend) + + csr = CertificateSigningRequestModule(module, module_backend) + if module.params['state'] == 'present': + csr.generate(module) + else: + csr.remove(module) + + result = csr.dump() + module.exit_json(**result) + except OpenSSLObjectError as exc: + module.fail_json(msg=to_native(exc)) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/crypto/plugins/modules/openssl_csr_info.py b/ansible_collections/community/crypto/plugins/modules/openssl_csr_info.py new file mode 100644 index 00000000..7ed0b1c4 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/modules/openssl_csr_info.py @@ -0,0 +1,359 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2016-2017, Yanis Guenane <yanis+ansible@guenane.org> +# Copyright (c) 2017, Markus Teufelberger <mteufelberger+ansible@mgit.at> +# 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 = r''' +--- +module: openssl_csr_info +short_description: Provide information of OpenSSL Certificate Signing Requests (CSR) +description: + - This module allows one to query information on OpenSSL Certificate Signing Requests (CSR). + - In case the CSR signature cannot be validated, the module will fail. In this case, all return + variables are still returned. + - It uses the cryptography python library to interact with OpenSSL. +requirements: + - cryptography >= 1.3 +author: + - Felix Fontein (@felixfontein) + - Yanis Guenane (@Spredzy) +extends_documentation_fragment: + - community.crypto.attributes + - community.crypto.attributes.info_module + - community.crypto.name_encoding +options: + path: + description: + - Remote absolute path where the CSR file is loaded from. + - Either I(path) or I(content) must be specified, but not both. + type: path + content: + description: + - Content of the CSR file. + - Either I(path) or I(content) must be specified, but not both. + type: str + version_added: "1.0.0" + select_crypto_backend: + description: + - Determines which crypto backend to use. + - The default choice is C(auto), which tries to use C(cryptography) if available. + - If set to C(cryptography), will try to use the L(cryptography,https://cryptography.io/) library. + type: str + default: auto + choices: [ auto, cryptography ] + +seealso: + - module: community.crypto.openssl_csr + - module: community.crypto.openssl_csr_pipe + - ref: community.crypto.openssl_csr_info filter <ansible_collections.community.crypto.openssl_csr_info_filter> + # - plugin: community.crypto.openssl_csr_info + # plugin_type: filter + description: A filter variant of this module. +''' + +EXAMPLES = r''' +- name: Generate an OpenSSL Certificate Signing Request + community.crypto.openssl_csr: + path: /etc/ssl/csr/www.ansible.com.csr + privatekey_path: /etc/ssl/private/ansible.com.pem + common_name: www.ansible.com + +- name: Get information on the CSR + community.crypto.openssl_csr_info: + path: /etc/ssl/csr/www.ansible.com.csr + register: result + +- name: Dump information + debug: + var: result +''' + +RETURN = r''' +signature_valid: + description: + - Whether the CSR's signature is valid. + - In case the check returns C(false), the module will fail. + returned: success + type: bool +basic_constraints: + description: Entries in the C(basic_constraints) extension, or C(none) if extension is not present. + returned: success + type: list + elements: str + sample: ['CA:TRUE', 'pathlen:1'] +basic_constraints_critical: + description: Whether the C(basic_constraints) extension is critical. + returned: success + type: bool +extended_key_usage: + description: Entries in the C(extended_key_usage) extension, or C(none) if extension is not present. + returned: success + type: list + elements: str + sample: [Biometric Info, DVCS, Time Stamping] +extended_key_usage_critical: + description: Whether the C(extended_key_usage) extension is critical. + returned: success + type: bool +extensions_by_oid: + description: Returns a dictionary for every extension OID + returned: success + type: dict + contains: + critical: + description: Whether the extension is critical. + returned: success + type: bool + value: + description: + - The Base64 encoded value (in DER format) of the extension. + - B(Note) that depending on the C(cryptography) version used, it is + not possible to extract the ASN.1 content of the extension, but only + to provide the re-encoded content of the extension in case it was + parsed by C(cryptography). This should usually result in exactly the + same value, except if the original extension value was malformed. + returned: success + type: str + sample: "MAMCAQU=" + sample: {"1.3.6.1.5.5.7.1.24": { "critical": false, "value": "MAMCAQU="}} +key_usage: + description: Entries in the C(key_usage) extension, or C(none) if extension is not present. + returned: success + type: str + sample: [Key Agreement, Data Encipherment] +key_usage_critical: + description: Whether the C(key_usage) extension is critical. + returned: success + type: bool +subject_alt_name: + description: + - Entries in the C(subject_alt_name) extension, or C(none) if extension is not present. + - See I(name_encoding) for how IDNs are handled. + returned: success + type: list + elements: str + sample: ["DNS:www.ansible.com", "IP:1.2.3.4"] +subject_alt_name_critical: + description: Whether the C(subject_alt_name) extension is critical. + returned: success + type: bool +ocsp_must_staple: + description: C(true) if the OCSP Must Staple extension is present, C(none) otherwise. + returned: success + type: bool +ocsp_must_staple_critical: + description: Whether the C(ocsp_must_staple) extension is critical. + returned: success + type: bool +name_constraints_permitted: + description: List of permitted subtrees to sign certificates for. + returned: success + type: list + elements: str + sample: ['email:.somedomain.com'] + version_added: 1.1.0 +name_constraints_excluded: + description: + - List of excluded subtrees the CA cannot sign certificates for. + - Is C(none) if extension is not present. + - See I(name_encoding) for how IDNs are handled. + returned: success + type: list + elements: str + sample: ['email:.com'] + version_added: 1.1.0 +name_constraints_critical: + description: + - Whether the C(name_constraints) extension is critical. + - Is C(none) if extension is not present. + returned: success + type: bool + version_added: 1.1.0 +subject: + description: + - The CSR's subject as a dictionary. + - Note that for repeated values, only the last one will be returned. + returned: success + type: dict + sample: {"commonName": "www.example.com", "emailAddress": "test@example.com"} +subject_ordered: + description: The CSR's subject as an ordered list of tuples. + returned: success + type: list + elements: list + sample: [["commonName", "www.example.com"], ["emailAddress": "test@example.com"]] +public_key: + description: CSR's public key in PEM format + returned: success + type: str + sample: "-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8A..." +public_key_type: + description: + - The CSR's public key's type. + - One of C(RSA), C(DSA), C(ECC), C(Ed25519), C(X25519), C(Ed448), or C(X448). + - Will start with C(unknown) if the key type cannot be determined. + returned: success + type: str + version_added: 1.7.0 + sample: RSA +public_key_data: + description: + - Public key data. Depends on the public key's type. + returned: success + type: dict + version_added: 1.7.0 + contains: + size: + description: + - Bit size of modulus (RSA) or prime number (DSA). + type: int + returned: When C(public_key_type=RSA) or C(public_key_type=DSA) + modulus: + description: + - The RSA key's modulus. + type: int + returned: When C(public_key_type=RSA) + exponent: + description: + - The RSA key's public exponent. + type: int + returned: When C(public_key_type=RSA) + p: + description: + - The C(p) value for DSA. + - This is the prime modulus upon which arithmetic takes place. + type: int + returned: When C(public_key_type=DSA) + q: + description: + - The C(q) value for DSA. + - This is a prime that divides C(p - 1), and at the same time the order of the subgroup of the + multiplicative group of the prime field used. + type: int + returned: When C(public_key_type=DSA) + g: + description: + - The C(g) value for DSA. + - This is the element spanning the subgroup of the multiplicative group of the prime field used. + type: int + returned: When C(public_key_type=DSA) + curve: + description: + - The curve's name for ECC. + type: str + returned: When C(public_key_type=ECC) + exponent_size: + description: + - The maximum number of bits of a private key. This is basically the bit size of the subgroup used. + type: int + returned: When C(public_key_type=ECC) + x: + description: + - The C(x) coordinate for the public point on the elliptic curve. + type: int + returned: When C(public_key_type=ECC) + y: + description: + - For C(public_key_type=ECC), this is the C(y) coordinate for the public point on the elliptic curve. + - For C(public_key_type=DSA), this is the publicly known group element whose discrete logarithm w.r.t. C(g) is the private key. + type: int + returned: When C(public_key_type=DSA) or C(public_key_type=ECC) +public_key_fingerprints: + description: + - Fingerprints of CSR's public key. + - For every hash algorithm available, the fingerprint is computed. + returned: success + type: dict + sample: "{'sha256': 'd4:b3:aa:6d:c8:04:ce:4e:ba:f6:29:4d:92:a3:94:b0:c2:ff:bd:bf:33:63:11:43:34:0f:51:b0:95:09:2f:63', + 'sha512': 'f7:07:4a:f0:b0:f0:e6:8b:95:5f:f9:e6:61:0a:32:68:f1..." +subject_key_identifier: + description: + - The CSR's subject key identifier. + - The identifier is returned in hexadecimal, with C(:) used to separate bytes. + - Is C(none) if the C(SubjectKeyIdentifier) extension is not present. + returned: success + type: str + sample: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff:00:11:22:33' +authority_key_identifier: + description: + - The CSR's authority key identifier. + - The identifier is returned in hexadecimal, with C(:) used to separate bytes. + - Is C(none) if the C(AuthorityKeyIdentifier) extension is not present. + returned: success + type: str + sample: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff:00:11:22:33' +authority_cert_issuer: + description: + - The CSR's authority cert issuer as a list of general names. + - Is C(none) if the C(AuthorityKeyIdentifier) extension is not present. + - See I(name_encoding) for how IDNs are handled. + returned: success + type: list + elements: str + sample: ["DNS:www.ansible.com", "IP:1.2.3.4"] +authority_cert_serial_number: + description: + - The CSR's authority cert serial number. + - Is C(none) if the C(AuthorityKeyIdentifier) extension is not present. + returned: success + type: int + sample: 12345 +''' + + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.common.text.converters import to_native + +from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import ( + OpenSSLObjectError, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.csr_info import ( + select_backend, +) + + +def main(): + module = AnsibleModule( + argument_spec=dict( + path=dict(type='path'), + content=dict(type='str'), + name_encoding=dict(type='str', default='ignore', choices=['ignore', 'idna', 'unicode']), + select_crypto_backend=dict(type='str', default='auto', choices=['auto', 'cryptography']), + ), + required_one_of=( + ['path', 'content'], + ), + mutually_exclusive=( + ['path', 'content'], + ), + supports_check_mode=True, + ) + + if module.params['content'] is not None: + data = module.params['content'].encode('utf-8') + else: + try: + with open(module.params['path'], 'rb') as f: + data = f.read() + except (IOError, OSError) as e: + module.fail_json(msg='Error while reading CSR file from disk: {0}'.format(e)) + + backend, module_backend = select_backend(module, module.params['select_crypto_backend'], data, validate_signature=True) + + try: + result = module_backend.get_info() + module.exit_json(**result) + except OpenSSLObjectError as exc: + module.fail_json(msg=to_native(exc)) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/crypto/plugins/modules/openssl_csr_pipe.py b/ansible_collections/community/crypto/plugins/modules/openssl_csr_pipe.py new file mode 100644 index 00000000..01a3fd79 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/modules/openssl_csr_pipe.py @@ -0,0 +1,183 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2017, Yanis Guenane <yanis+ansible@guenane.org> +# 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 + + +DOCUMENTATION = r''' +--- +module: openssl_csr_pipe +short_description: Generate OpenSSL Certificate Signing Request (CSR) +version_added: 1.3.0 +description: + - "Please note that the module regenerates an existing CSR if it does not match the module's + options, or if it seems to be corrupt." +author: + - Yanis Guenane (@Spredzy) + - Felix Fontein (@felixfontein) +extends_documentation_fragment: + - community.crypto.attributes + - community.crypto.module_csr +attributes: + check_mode: + support: full + diff_mode: + support: full +options: + content: + description: + - The existing CSR. + type: str +seealso: +- module: community.crypto.openssl_csr +''' + +EXAMPLES = r''' +- name: Generate an OpenSSL Certificate Signing Request + community.crypto.openssl_csr_pipe: + privatekey_path: /etc/ssl/private/ansible.com.pem + common_name: www.ansible.com + register: result +- debug: + var: result.csr + +- name: Generate an OpenSSL Certificate Signing Request with an inline CSR + community.crypto.openssl_csr: + content: "{{ lookup('file', '/etc/ssl/csr/www.ansible.com.csr') }}" + privatekey_content: "{{ private_key_content }}" + common_name: www.ansible.com + register: result +- name: Store CSR + ansible.builtin.copy: + dest: /etc/ssl/csr/www.ansible.com.csr + content: "{{ result.csr }}" + when: result is changed +''' + +RETURN = r''' +privatekey: + description: + - Path to the TLS/SSL private key the CSR was generated for + - Will be C(none) if the private key has been provided in I(privatekey_content). + returned: changed or success + type: str + sample: /etc/ssl/private/ansible.com.pem +subject: + description: A list of the subject tuples attached to the CSR + returned: changed or success + type: list + elements: list + sample: [['CN', 'www.ansible.com'], ['O', 'Ansible']] +subjectAltName: + description: The alternative names this CSR is valid for + returned: changed or success + type: list + elements: str + sample: [ 'DNS:www.ansible.com', 'DNS:m.ansible.com' ] +keyUsage: + description: Purpose for which the public key may be used + returned: changed or success + type: list + elements: str + sample: [ 'digitalSignature', 'keyAgreement' ] +extendedKeyUsage: + description: Additional restriction on the public key purposes + returned: changed or success + type: list + elements: str + sample: [ 'clientAuth' ] +basicConstraints: + description: Indicates if the certificate belongs to a CA + returned: changed or success + type: list + elements: str + sample: ['CA:TRUE', 'pathLenConstraint:0'] +ocsp_must_staple: + description: Indicates whether the certificate has the OCSP + Must Staple feature enabled + returned: changed or success + type: bool + sample: false +name_constraints_permitted: + description: List of permitted subtrees to sign certificates for. + returned: changed or success + type: list + elements: str + sample: ['email:.somedomain.com'] +name_constraints_excluded: + description: List of excluded subtrees the CA cannot sign certificates for. + returned: changed or success + type: list + elements: str + sample: ['email:.com'] +csr: + description: The (current or generated) CSR's content. + returned: changed or success + type: str +''' + +from ansible.module_utils.common.text.converters import to_native + +from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.csr import ( + select_backend, + get_csr_argument_spec, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import ( + OpenSSLObjectError, +) + + +class CertificateSigningRequestModule(object): + def __init__(self, module, module_backend): + self.check_mode = module.check_mode + self.module_backend = module_backend + self.changed = False + if module.params['content'] is not None: + self.module_backend.set_existing(module.params['content'].encode('utf-8')) + + def generate(self, module): + '''Generate the certificate signing request.''' + if self.module_backend.needs_regeneration(): + if not self.check_mode: + self.module_backend.generate_csr() + self.changed = True + + def dump(self): + '''Serialize the object into a dictionary.''' + result = self.module_backend.dump(include_csr=True) + result.update({ + 'changed': self.changed, + }) + return result + + +def main(): + argument_spec = get_csr_argument_spec() + argument_spec.argument_spec.update(dict( + content=dict(type='str'), + )) + module = argument_spec.create_ansible_module( + supports_check_mode=True, + ) + + try: + backend = module.params['select_crypto_backend'] + backend, module_backend = select_backend(module, backend) + + csr = CertificateSigningRequestModule(module, module_backend) + csr.generate(module) + result = csr.dump() + module.exit_json(**result) + except OpenSSLObjectError as exc: + module.fail_json(msg=to_native(exc)) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/crypto/plugins/modules/openssl_dhparam.py b/ansible_collections/community/crypto/plugins/modules/openssl_dhparam.py new file mode 100644 index 00000000..d9e1e982 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/modules/openssl_dhparam.py @@ -0,0 +1,431 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2017, Thom Wiggers <ansible@thomwiggers.nl> +# 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 = r''' +--- +module: openssl_dhparam +short_description: Generate OpenSSL Diffie-Hellman Parameters +description: + - This module allows one to (re)generate OpenSSL DH-params. + - This module uses file common arguments to specify generated file permissions. + - "Please note that the module regenerates existing DH params if they do not + match the module's options. If you are concerned that this could overwrite + your existing DH params, consider using the I(backup) option." + - The module can use the cryptography Python library, or the C(openssl) executable. + By default, it tries to detect which one is available. This can be overridden + with the I(select_crypto_backend) option. +requirements: + - Either cryptography >= 2.0 + - Or OpenSSL binary C(openssl) +author: + - Thom Wiggers (@thomwiggers) +extends_documentation_fragment: + - ansible.builtin.files + - community.crypto.attributes + - community.crypto.attributes.files +attributes: + check_mode: + support: full + diff_mode: + support: none + safe_file_operations: + support: full +options: + state: + description: + - Whether the parameters should exist or not, + taking action if the state is different from what is stated. + type: str + default: present + choices: [ absent, present ] + size: + description: + - Size (in bits) of the generated DH-params. + type: int + default: 4096 + force: + description: + - Should the parameters be regenerated even it it already exists. + type: bool + default: false + path: + description: + - Name of the file in which the generated parameters will be saved. + type: path + required: true + backup: + description: + - Create a backup file including a timestamp so you can get the original + DH params back if you overwrote them with new ones by accident. + type: bool + default: false + select_crypto_backend: + description: + - Determines which crypto backend to use. + - The default choice is C(auto), which tries to use C(cryptography) if available, and falls back to C(openssl). + - If set to C(openssl), will try to use the OpenSSL C(openssl) executable. + - If set to C(cryptography), will try to use the L(cryptography,https://cryptography.io/) library. + type: str + default: auto + choices: [ auto, cryptography, openssl ] + version_added: "1.0.0" + return_content: + description: + - If set to C(true), will return the (current or generated) DH parameter's content as I(dhparams). + type: bool + default: false + version_added: "1.0.0" +seealso: + - module: community.crypto.x509_certificate + - module: community.crypto.openssl_csr + - module: community.crypto.openssl_pkcs12 + - module: community.crypto.openssl_privatekey + - module: community.crypto.openssl_publickey +''' + +EXAMPLES = r''' +- name: Generate Diffie-Hellman parameters with the default size (4096 bits) + community.crypto.openssl_dhparam: + path: /etc/ssl/dhparams.pem + +- name: Generate DH Parameters with a different size (2048 bits) + community.crypto.openssl_dhparam: + path: /etc/ssl/dhparams.pem + size: 2048 + +- name: Force regenerate an DH parameters if they already exist + community.crypto.openssl_dhparam: + path: /etc/ssl/dhparams.pem + force: true +''' + +RETURN = r''' +size: + description: Size (in bits) of the Diffie-Hellman parameters. + returned: changed or success + type: int + sample: 4096 +filename: + description: Path to the generated Diffie-Hellman parameters. + returned: changed or success + type: str + sample: /etc/ssl/dhparams.pem +backup_file: + description: Name of backup file created. + returned: changed and if I(backup) is C(true) + type: str + sample: /path/to/dhparams.pem.2019-03-09@11:22~ +dhparams: + description: The (current or generated) DH params' content. + returned: if I(state) is C(present) and I(return_content) is C(true) + type: str + version_added: "1.0.0" +''' + +import abc +import os +import re +import tempfile +import traceback + +from ansible.module_utils.basic import AnsibleModule, missing_required_lib +from ansible.module_utils.common.text.converters import to_native + +from ansible_collections.community.crypto.plugins.module_utils.version import LooseVersion + +from ansible_collections.community.crypto.plugins.module_utils.io import ( + load_file_if_exists, + write_file, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.math import ( + count_bits, +) + +MINIMAL_CRYPTOGRAPHY_VERSION = '2.0' + +CRYPTOGRAPHY_IMP_ERR = None +try: + import cryptography + import cryptography.exceptions + import cryptography.hazmat.backends + import cryptography.hazmat.primitives.asymmetric.dh + import cryptography.hazmat.primitives.serialization + CRYPTOGRAPHY_VERSION = LooseVersion(cryptography.__version__) +except ImportError: + CRYPTOGRAPHY_IMP_ERR = traceback.format_exc() + CRYPTOGRAPHY_FOUND = False +else: + CRYPTOGRAPHY_FOUND = True + + +class DHParameterError(Exception): + pass + + +class DHParameterBase(object): + + def __init__(self, module): + self.state = module.params['state'] + self.path = module.params['path'] + self.size = module.params['size'] + self.force = module.params['force'] + self.changed = False + self.return_content = module.params['return_content'] + + self.backup = module.params['backup'] + self.backup_file = None + + @abc.abstractmethod + def _do_generate(self, module): + """Actually generate the DH params.""" + pass + + def generate(self, module): + """Generate DH params.""" + changed = False + + # ony generate when necessary + if self.force or not self._check_params_valid(module): + self._do_generate(module) + changed = True + + # fix permissions (checking force not necessary as done above) + if not self._check_fs_attributes(module): + # Fix done implicitly by + # AnsibleModule.set_fs_attributes_if_different + changed = True + + self.changed = changed + + def remove(self, module): + if self.backup: + self.backup_file = module.backup_local(self.path) + try: + os.remove(self.path) + self.changed = True + except OSError as exc: + module.fail_json(msg=to_native(exc)) + + def check(self, module): + """Ensure the resource is in its desired state.""" + if self.force: + return False + return self._check_params_valid(module) and self._check_fs_attributes(module) + + @abc.abstractmethod + def _check_params_valid(self, module): + """Check if the params are in the correct state""" + pass + + def _check_fs_attributes(self, module): + """Checks (and changes if not in check mode!) fs attributes""" + file_args = module.load_file_common_arguments(module.params) + if module.check_file_absent_if_check_mode(file_args['path']): + return False + return not module.set_fs_attributes_if_different(file_args, False) + + def dump(self): + """Serialize the object into a dictionary.""" + + result = { + 'size': self.size, + 'filename': self.path, + 'changed': self.changed, + } + if self.backup_file: + result['backup_file'] = self.backup_file + if self.return_content: + content = load_file_if_exists(self.path, ignore_errors=True) + result['dhparams'] = content.decode('utf-8') if content else None + + return result + + +class DHParameterAbsent(DHParameterBase): + + def __init__(self, module): + super(DHParameterAbsent, self).__init__(module) + + def _do_generate(self, module): + """Actually generate the DH params.""" + pass + + def _check_params_valid(self, module): + """Check if the params are in the correct state""" + pass + + +class DHParameterOpenSSL(DHParameterBase): + + def __init__(self, module): + super(DHParameterOpenSSL, self).__init__(module) + self.openssl_bin = module.get_bin_path('openssl', True) + + def _do_generate(self, module): + """Actually generate the DH params.""" + # create a tempfile + fd, tmpsrc = tempfile.mkstemp() + os.close(fd) + module.add_cleanup_file(tmpsrc) # Ansible will delete the file on exit + # openssl dhparam -out <path> <bits> + command = [self.openssl_bin, 'dhparam', '-out', tmpsrc, str(self.size)] + rc, dummy, err = module.run_command(command, check_rc=False) + if rc != 0: + raise DHParameterError(to_native(err)) + if self.backup: + self.backup_file = module.backup_local(self.path) + try: + module.atomic_move(tmpsrc, self.path) + except Exception as e: + module.fail_json(msg="Failed to write to file %s: %s" % (self.path, str(e))) + + def _check_params_valid(self, module): + """Check if the params are in the correct state""" + command = [self.openssl_bin, 'dhparam', '-check', '-text', '-noout', '-in', self.path] + rc, out, err = module.run_command(command, check_rc=False) + result = to_native(out) + if rc != 0: + # If the call failed the file probably does not exist or is + # unreadable + return False + # output contains "(xxxx bit)" + match = re.search(r"Parameters:\s+\((\d+) bit\).*", result) + if not match: + return False # No "xxxx bit" in output + + bits = int(match.group(1)) + + # if output contains "WARNING" we've got a problem + if "WARNING" in result or "WARNING" in to_native(err): + return False + + return bits == self.size + + +class DHParameterCryptography(DHParameterBase): + + def __init__(self, module): + super(DHParameterCryptography, self).__init__(module) + self.crypto_backend = cryptography.hazmat.backends.default_backend() + + def _do_generate(self, module): + """Actually generate the DH params.""" + # Generate parameters + params = cryptography.hazmat.primitives.asymmetric.dh.generate_parameters( + generator=2, + key_size=self.size, + backend=self.crypto_backend, + ) + # Serialize parameters + result = params.parameter_bytes( + encoding=cryptography.hazmat.primitives.serialization.Encoding.PEM, + format=cryptography.hazmat.primitives.serialization.ParameterFormat.PKCS3, + ) + # Write result + if self.backup: + self.backup_file = module.backup_local(self.path) + write_file(module, result) + + def _check_params_valid(self, module): + """Check if the params are in the correct state""" + # Load parameters + try: + with open(self.path, 'rb') as f: + data = f.read() + params = self.crypto_backend.load_pem_parameters(data) + except Exception as dummy: + return False + # Check parameters + bits = count_bits(params.parameter_numbers().p) + return bits == self.size + + +def main(): + """Main function""" + + module = AnsibleModule( + argument_spec=dict( + state=dict(type='str', default='present', choices=['absent', 'present']), + size=dict(type='int', default=4096), + force=dict(type='bool', default=False), + path=dict(type='path', required=True), + backup=dict(type='bool', default=False), + select_crypto_backend=dict(type='str', default='auto', choices=['auto', 'cryptography', 'openssl']), + return_content=dict(type='bool', default=False), + ), + supports_check_mode=True, + add_file_common_args=True, + ) + + base_dir = os.path.dirname(module.params['path']) or '.' + if not os.path.isdir(base_dir): + module.fail_json( + name=base_dir, + msg="The directory '%s' does not exist or the file is not a directory" % base_dir + ) + + if module.params['state'] == 'present': + backend = module.params['select_crypto_backend'] + if backend == 'auto': + # Detection what is possible + can_use_cryptography = CRYPTOGRAPHY_FOUND and CRYPTOGRAPHY_VERSION >= LooseVersion(MINIMAL_CRYPTOGRAPHY_VERSION) + can_use_openssl = module.get_bin_path('openssl', False) is not None + + # First try cryptography, then OpenSSL + if can_use_cryptography: + backend = 'cryptography' + elif can_use_openssl: + backend = 'openssl' + + # Success? + if backend == 'auto': + module.fail_json(msg=("Cannot detect either the required Python library cryptography (>= {0}) " + "or the OpenSSL binary openssl").format(MINIMAL_CRYPTOGRAPHY_VERSION)) + + if backend == 'openssl': + dhparam = DHParameterOpenSSL(module) + elif backend == 'cryptography': + if not CRYPTOGRAPHY_FOUND: + module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)), + exception=CRYPTOGRAPHY_IMP_ERR) + dhparam = DHParameterCryptography(module) + + if module.check_mode: + result = dhparam.dump() + result['changed'] = module.params['force'] or not dhparam.check(module) + module.exit_json(**result) + + try: + dhparam.generate(module) + except DHParameterError as exc: + module.fail_json(msg=to_native(exc)) + else: + dhparam = DHParameterAbsent(module) + + if module.check_mode: + result = dhparam.dump() + result['changed'] = os.path.exists(module.params['path']) + module.exit_json(**result) + + if os.path.exists(module.params['path']): + try: + dhparam.remove(module) + except Exception as exc: + module.fail_json(msg=to_native(exc)) + + result = dhparam.dump() + + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/crypto/plugins/modules/openssl_pkcs12.py b/ansible_collections/community/crypto/plugins/modules/openssl_pkcs12.py new file mode 100644 index 00000000..e74553b5 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/modules/openssl_pkcs12.py @@ -0,0 +1,848 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2017, Guillaume Delpierre <gde@llew.me> +# 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 = r''' +--- +module: openssl_pkcs12 +author: +- Guillaume Delpierre (@gdelpierre) +short_description: Generate OpenSSL PKCS#12 archive +description: + - This module allows one to (re-)generate PKCS#12. + - The module can use the cryptography Python library, or the pyOpenSSL Python + library. By default, it tries to detect which one is available, assuming none of the + I(iter_size) and I(maciter_size) options are used. This can be overridden with the + I(select_crypto_backend) option. + # Please note that the C(pyopenssl) backend has been deprecated in community.crypto x.y.0, + # and will be removed in community.crypto (x+1).0.0. +requirements: + - PyOpenSSL >= 0.15 or cryptography >= 3.0 +extends_documentation_fragment: + - ansible.builtin.files + - community.crypto.attributes + - community.crypto.attributes.files +attributes: + check_mode: + support: full + diff_mode: + support: none + safe_file_operations: + support: full +options: + action: + description: + - C(export) or C(parse) a PKCS#12. + type: str + default: export + choices: [ export, parse ] + other_certificates: + description: + - List of other certificates to include. Pre Ansible 2.8 this parameter was called I(ca_certificates). + - Assumes there is one PEM-encoded certificate per file. If a file contains multiple PEM certificates, + set I(other_certificates_parse_all) to C(true). + type: list + elements: path + aliases: [ ca_certificates ] + other_certificates_parse_all: + description: + - If set to C(true), assumes that the files mentioned in I(other_certificates) can contain more than one + certificate per file (or even none per file). + type: bool + default: false + version_added: 1.4.0 + certificate_path: + description: + - The path to read certificates and private keys from. + - Must be in PEM format. + type: path + force: + description: + - Should the file be regenerated even if it already exists. + type: bool + default: false + friendly_name: + description: + - Specifies the friendly name for the certificate and private key. + type: str + aliases: [ name ] + iter_size: + description: + - Number of times to repeat the encryption step. + - This is B(not considered during idempotency checks). + - This is only used by the C(pyopenssl) backend, or when I(encryption_level=compatibility2022). + - When using it, the default is C(2048) for C(pyopenssl) and C(50000) for C(cryptography). + type: int + maciter_size: + description: + - Number of times to repeat the MAC step. + - This is B(not considered during idempotency checks). + - This is only used by the C(pyopenssl) backend. When using it, the default is C(1). + type: int + encryption_level: + description: + - Determines the encryption level used. + - C(auto) uses the default of the selected backend. For C(cryptography), this is what the + cryptography library's specific version considers the best available encryption. + - C(compatibility2022) uses compatibility settings for older software in 2022. + This is only supported by the C(cryptography) backend if cryptography >= 38.0.0 is available. + - B(Note) that this option is B(not used for idempotency). + choices: + - auto + - compatibility2022 + default: auto + type: str + version_added: 2.8.0 + passphrase: + description: + - The PKCS#12 password. + - "B(Note:) PKCS12 encryption is not secure and should not be used as a security mechanism. + If you need to store or send a PKCS12 file safely, you should additionally encrypt it + with something else." + type: str + path: + description: + - Filename to write the PKCS#12 file to. + type: path + required: true + privatekey_passphrase: + description: + - Passphrase source to decrypt any input private keys with. + type: str + privatekey_path: + description: + - File to read private key from. + - Mutually exclusive with I(privatekey_content). + type: path + privatekey_content: + description: + - Content of the private key file. + - Mutually exclusive with I(privatekey_path). + type: str + version_added: "2.3.0" + state: + description: + - Whether the file should exist or not. + All parameters except C(path) are ignored when state is C(absent). + choices: [ absent, present ] + default: present + type: str + src: + description: + - PKCS#12 file path to parse. + type: path + backup: + description: + - Create a backup file including a timestamp so you can get the original + output file back if you overwrote it with a new one by accident. + type: bool + default: false + return_content: + description: + - If set to C(true), will return the (current or generated) PKCS#12's content as I(pkcs12). + type: bool + default: false + version_added: "1.0.0" + select_crypto_backend: + description: + - Determines which crypto backend to use. + - The default choice is C(auto), which tries to use C(cryptography) if available, and falls back to C(pyopenssl). + If I(iter_size) is used together with I(encryption_level != compatibility2022), or if I(maciter_size) is used, + C(auto) will always result in C(pyopenssl) to be chosen for backwards compatibility. + - If set to C(pyopenssl), will try to use the L(pyOpenSSL,https://pypi.org/project/pyOpenSSL/) library. + - If set to C(cryptography), will try to use the L(cryptography,https://cryptography.io/) library. + # - Please note that the C(pyopenssl) backend has been deprecated in community.crypto x.y.0, and will be + # removed in community.crypto (x+1).0.0. + # From that point on, only the C(cryptography) backend will be available. + type: str + default: auto + choices: [ auto, cryptography, pyopenssl ] + version_added: 1.7.0 +seealso: + - module: community.crypto.x509_certificate + - module: community.crypto.openssl_csr + - module: community.crypto.openssl_dhparam + - module: community.crypto.openssl_privatekey + - module: community.crypto.openssl_publickey +''' + +EXAMPLES = r''' +- name: Generate PKCS#12 file + community.crypto.openssl_pkcs12: + action: export + path: /opt/certs/ansible.p12 + friendly_name: raclette + privatekey_path: /opt/certs/keys/key.pem + certificate_path: /opt/certs/cert.pem + other_certificates: /opt/certs/ca.pem + # Note that if /opt/certs/ca.pem contains multiple certificates, + # only the first one will be used. See the other_certificates_parse_all + # option for changing this behavior. + state: present + +- name: Generate PKCS#12 file + community.crypto.openssl_pkcs12: + action: export + path: /opt/certs/ansible.p12 + friendly_name: raclette + privatekey_content: '{{ private_key_contents }}' + certificate_path: /opt/certs/cert.pem + other_certificates_parse_all: true + other_certificates: + - /opt/certs/ca_bundle.pem + # Since we set other_certificates_parse_all to true, all + # certificates in the CA bundle are included and not just + # the first one. + - /opt/certs/intermediate.pem + # In case this file has multiple certificates in it, + # all will be included as well. + state: present + +- name: Change PKCS#12 file permission + community.crypto.openssl_pkcs12: + action: export + path: /opt/certs/ansible.p12 + friendly_name: raclette + privatekey_path: /opt/certs/keys/key.pem + certificate_path: /opt/certs/cert.pem + other_certificates: /opt/certs/ca.pem + state: present + mode: '0600' + +- name: Regen PKCS#12 file + community.crypto.openssl_pkcs12: + action: export + src: /opt/certs/ansible.p12 + path: /opt/certs/ansible.p12 + friendly_name: raclette + privatekey_path: /opt/certs/keys/key.pem + certificate_path: /opt/certs/cert.pem + other_certificates: /opt/certs/ca.pem + state: present + mode: '0600' + force: true + +- name: Dump/Parse PKCS#12 file + community.crypto.openssl_pkcs12: + action: parse + src: /opt/certs/ansible.p12 + path: /opt/certs/ansible.pem + state: present + +- name: Remove PKCS#12 file + community.crypto.openssl_pkcs12: + path: /opt/certs/ansible.p12 + state: absent +''' + +RETURN = r''' +filename: + description: Path to the generate PKCS#12 file. + returned: changed or success + type: str + sample: /opt/certs/ansible.p12 +privatekey: + description: Path to the TLS/SSL private key the public key was generated from. + returned: changed or success + type: str + sample: /etc/ssl/private/ansible.com.pem +backup_file: + description: Name of backup file created. + returned: changed and if I(backup) is C(true) + type: str + sample: /path/to/ansible.com.pem.2019-03-09@11:22~ +pkcs12: + description: The (current or generated) PKCS#12's content Base64 encoded. + returned: if I(state) is C(present) and I(return_content) is C(true) + type: str + version_added: "1.0.0" +''' + +import abc +import base64 +import os +import stat +import traceback + +from ansible.module_utils.basic import AnsibleModule, missing_required_lib +from ansible.module_utils.common.text.converters import to_bytes, to_native + +from ansible_collections.community.crypto.plugins.module_utils.version import LooseVersion + +from ansible_collections.community.crypto.plugins.module_utils.io import ( + load_file_if_exists, + write_file, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import ( + OpenSSLObjectError, + OpenSSLBadPassphraseError, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_support import ( + parse_pkcs12, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.support import ( + OpenSSLObject, + load_privatekey, + load_certificate, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.pem import ( + split_pem_list, +) + +MINIMAL_CRYPTOGRAPHY_VERSION = '3.0' +MINIMAL_PYOPENSSL_VERSION = '0.15' + +PYOPENSSL_IMP_ERR = None +try: + import OpenSSL + from OpenSSL import crypto + PYOPENSSL_VERSION = LooseVersion(OpenSSL.__version__) +except (ImportError, AttributeError): + PYOPENSSL_IMP_ERR = traceback.format_exc() + PYOPENSSL_FOUND = False +else: + PYOPENSSL_FOUND = True + +CRYPTOGRAPHY_IMP_ERR = None +try: + import cryptography + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.serialization.pkcs12 import serialize_key_and_certificates + CRYPTOGRAPHY_VERSION = LooseVersion(cryptography.__version__) +except ImportError: + CRYPTOGRAPHY_IMP_ERR = traceback.format_exc() + CRYPTOGRAPHY_FOUND = False +else: + CRYPTOGRAPHY_FOUND = True + +CRYPTOGRAPHY_COMPATIBILITY2022_ERR = None +try: + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.serialization.pkcs12 import PBES + # Try to build encryption builder for compatibility2022 + serialization.PrivateFormat.PKCS12.encryption_builder().key_cert_algorithm(PBES.PBESv1SHA1And3KeyTripleDESCBC).hmac_hash(hashes.SHA1()) +except Exception: + CRYPTOGRAPHY_COMPATIBILITY2022_ERR = traceback.format_exc() + CRYPTOGRAPHY_HAS_COMPATIBILITY2022 = False +else: + CRYPTOGRAPHY_HAS_COMPATIBILITY2022 = True + + +def load_certificate_set(filename, backend): + ''' + Load list of concatenated PEM files, and return a list of parsed certificates. + ''' + with open(filename, 'rb') as f: + data = f.read().decode('utf-8') + return [load_certificate(None, content=cert.encode('utf-8'), backend=backend) for cert in split_pem_list(data)] + + +class PkcsError(OpenSSLObjectError): + pass + + +class Pkcs(OpenSSLObject): + def __init__(self, module, backend, iter_size_default=2048): + super(Pkcs, self).__init__( + module.params['path'], + module.params['state'], + module.params['force'], + module.check_mode + ) + self.backend = backend + self.action = module.params['action'] + self.other_certificates = module.params['other_certificates'] + self.other_certificates_parse_all = module.params['other_certificates_parse_all'] + self.certificate_path = module.params['certificate_path'] + self.friendly_name = module.params['friendly_name'] + self.iter_size = module.params['iter_size'] or iter_size_default + self.maciter_size = module.params['maciter_size'] or 1 + self.encryption_level = module.params['encryption_level'] + self.passphrase = module.params['passphrase'] + self.pkcs12 = None + self.privatekey_passphrase = module.params['privatekey_passphrase'] + self.privatekey_path = module.params['privatekey_path'] + self.privatekey_content = module.params['privatekey_content'] + self.pkcs12_bytes = None + self.return_content = module.params['return_content'] + self.src = module.params['src'] + + if module.params['mode'] is None: + module.params['mode'] = '0400' + + self.backup = module.params['backup'] + self.backup_file = None + + if self.privatekey_path is not None: + try: + with open(self.privatekey_path, 'rb') as fh: + self.privatekey_content = fh.read() + except (IOError, OSError) as exc: + raise PkcsError(exc) + elif self.privatekey_content is not None: + self.privatekey_content = to_bytes(self.privatekey_content) + + if self.other_certificates: + if self.other_certificates_parse_all: + filenames = list(self.other_certificates) + self.other_certificates = [] + for other_cert_bundle in filenames: + self.other_certificates.extend(load_certificate_set(other_cert_bundle, self.backend)) + else: + self.other_certificates = [ + load_certificate(other_cert, backend=self.backend) for other_cert in self.other_certificates + ] + + @abc.abstractmethod + def generate_bytes(self, module): + """Generate PKCS#12 file archive.""" + pass + + @abc.abstractmethod + def parse_bytes(self, pkcs12_content): + pass + + @abc.abstractmethod + def _dump_privatekey(self, pkcs12): + pass + + @abc.abstractmethod + def _dump_certificate(self, pkcs12): + pass + + @abc.abstractmethod + def _dump_other_certificates(self, pkcs12): + pass + + @abc.abstractmethod + def _get_friendly_name(self, pkcs12): + pass + + def check(self, module, perms_required=True): + """Ensure the resource is in its desired state.""" + + state_and_perms = super(Pkcs, self).check(module, perms_required) + + def _check_pkey_passphrase(): + if self.privatekey_passphrase: + try: + load_privatekey(None, content=self.privatekey_content, passphrase=self.privatekey_passphrase, backend=self.backend) + except OpenSSLObjectError: + return False + return True + + if not state_and_perms: + return state_and_perms + + if os.path.exists(self.path) and module.params['action'] == 'export': + dummy = self.generate_bytes(module) + self.src = self.path + try: + pkcs12_privatekey, pkcs12_certificate, pkcs12_other_certificates, pkcs12_friendly_name = self.parse() + except OpenSSLObjectError: + return False + if (pkcs12_privatekey is not None) and (self.privatekey_content is not None): + expected_pkey = self._dump_privatekey(self.pkcs12) + if pkcs12_privatekey != expected_pkey: + return False + elif bool(pkcs12_privatekey) != bool(self.privatekey_content): + return False + + if (pkcs12_certificate is not None) and (self.certificate_path is not None): + expected_cert = self._dump_certificate(self.pkcs12) + if pkcs12_certificate != expected_cert: + return False + elif bool(pkcs12_certificate) != bool(self.certificate_path): + return False + + if (pkcs12_other_certificates is not None) and (self.other_certificates is not None): + expected_other_certs = self._dump_other_certificates(self.pkcs12) + if set(pkcs12_other_certificates) != set(expected_other_certs): + return False + elif bool(pkcs12_other_certificates) != bool(self.other_certificates): + return False + + if pkcs12_privatekey: + # This check is required because pyOpenSSL will not return a friendly name + # if the private key is not set in the file + friendly_name = self._get_friendly_name(self.pkcs12) + if ((friendly_name is not None) and (pkcs12_friendly_name is not None)): + if friendly_name != pkcs12_friendly_name: + return False + elif bool(friendly_name) != bool(pkcs12_friendly_name): + return False + elif module.params['action'] == 'parse' and os.path.exists(self.src) and os.path.exists(self.path): + try: + pkey, cert, other_certs, friendly_name = self.parse() + except OpenSSLObjectError: + return False + expected_content = to_bytes( + ''.join([to_native(pem) for pem in [pkey, cert] + other_certs if pem is not None]) + ) + dumped_content = load_file_if_exists(self.path, ignore_errors=True) + if expected_content != dumped_content: + return False + else: + return False + + return _check_pkey_passphrase() + + def dump(self): + """Serialize the object into a dictionary.""" + + result = { + 'filename': self.path, + } + if self.privatekey_path: + result['privatekey_path'] = self.privatekey_path + if self.backup_file: + result['backup_file'] = self.backup_file + if self.return_content: + if self.pkcs12_bytes is None: + self.pkcs12_bytes = load_file_if_exists(self.path, ignore_errors=True) + result['pkcs12'] = base64.b64encode(self.pkcs12_bytes) if self.pkcs12_bytes else None + + return result + + def remove(self, module): + if self.backup: + self.backup_file = module.backup_local(self.path) + super(Pkcs, self).remove(module) + + def parse(self): + """Read PKCS#12 file.""" + + try: + with open(self.src, 'rb') as pkcs12_fh: + pkcs12_content = pkcs12_fh.read() + return self.parse_bytes(pkcs12_content) + except IOError as exc: + raise PkcsError(exc) + + def generate(self): + pass + + def write(self, module, content, mode=None): + """Write the PKCS#12 file.""" + if self.backup: + self.backup_file = module.backup_local(self.path) + write_file(module, content, mode) + if self.return_content: + self.pkcs12_bytes = content + + +class PkcsPyOpenSSL(Pkcs): + def __init__(self, module): + super(PkcsPyOpenSSL, self).__init__(module, 'pyopenssl') + if self.encryption_level != 'auto': + module.fail_json(msg='The PyOpenSSL backend only supports encryption_level = auto') + + def generate_bytes(self, module): + """Generate PKCS#12 file archive.""" + self.pkcs12 = crypto.PKCS12() + + if self.other_certificates: + self.pkcs12.set_ca_certificates(self.other_certificates) + + if self.certificate_path: + self.pkcs12.set_certificate(load_certificate(self.certificate_path, backend=self.backend)) + + if self.friendly_name: + self.pkcs12.set_friendlyname(to_bytes(self.friendly_name)) + + if self.privatekey_content: + try: + self.pkcs12.set_privatekey( + load_privatekey(None, content=self.privatekey_content, passphrase=self.privatekey_passphrase, backend=self.backend)) + except OpenSSLBadPassphraseError as exc: + raise PkcsError(exc) + + return self.pkcs12.export(self.passphrase, self.iter_size, self.maciter_size) + + def parse_bytes(self, pkcs12_content): + try: + p12 = crypto.load_pkcs12(pkcs12_content, self.passphrase) + pkey = p12.get_privatekey() + if pkey is not None: + pkey = crypto.dump_privatekey(crypto.FILETYPE_PEM, pkey) + crt = p12.get_certificate() + if crt is not None: + crt = crypto.dump_certificate(crypto.FILETYPE_PEM, crt) + other_certs = [] + if p12.get_ca_certificates() is not None: + other_certs = [crypto.dump_certificate(crypto.FILETYPE_PEM, + other_cert) for other_cert in p12.get_ca_certificates()] + + friendly_name = p12.get_friendlyname() + + return (pkey, crt, other_certs, friendly_name) + except crypto.Error as exc: + raise PkcsError(exc) + + def _dump_privatekey(self, pkcs12): + pk = pkcs12.get_privatekey() + return crypto.dump_privatekey(crypto.FILETYPE_PEM, pk) if pk else None + + def _dump_certificate(self, pkcs12): + cert = pkcs12.get_certificate() + return crypto.dump_certificate(crypto.FILETYPE_PEM, cert) if cert else None + + def _dump_other_certificates(self, pkcs12): + if pkcs12.get_ca_certificates() is None: + return [] + return [ + crypto.dump_certificate(crypto.FILETYPE_PEM, other_cert) + for other_cert in pkcs12.get_ca_certificates() + ] + + def _get_friendly_name(self, pkcs12): + return pkcs12.get_friendlyname() + + +class PkcsCryptography(Pkcs): + def __init__(self, module): + super(PkcsCryptography, self).__init__(module, 'cryptography', iter_size_default=50000) + if self.encryption_level == 'compatibility2022' and not CRYPTOGRAPHY_HAS_COMPATIBILITY2022: + module.fail_json( + msg='The installed cryptography version does not support encryption_level = compatibility2022.' + ' You need cryptography >= 38.0.0 and support for SHA1', + exception=CRYPTOGRAPHY_COMPATIBILITY2022_ERR) + + def generate_bytes(self, module): + """Generate PKCS#12 file archive.""" + pkey = None + if self.privatekey_content: + try: + pkey = load_privatekey(None, content=self.privatekey_content, passphrase=self.privatekey_passphrase, backend=self.backend) + except OpenSSLBadPassphraseError as exc: + raise PkcsError(exc) + + cert = None + if self.certificate_path: + cert = load_certificate(self.certificate_path, backend=self.backend) + + friendly_name = to_bytes(self.friendly_name) if self.friendly_name is not None else None + + # Store fake object which can be used to retrieve the components back + self.pkcs12 = (pkey, cert, self.other_certificates, friendly_name) + + if not self.passphrase: + encryption = serialization.NoEncryption() + elif self.encryption_level == 'compatibility2022': + encryption = ( + serialization.PrivateFormat.PKCS12.encryption_builder(). + kdf_rounds(self.iter_size). + key_cert_algorithm(PBES.PBESv1SHA1And3KeyTripleDESCBC). + hmac_hash(hashes.SHA1()). + build(to_bytes(self.passphrase)) + ) + else: + encryption = serialization.BestAvailableEncryption(to_bytes(self.passphrase)) + + return serialize_key_and_certificates( + friendly_name, + pkey, + cert, + self.other_certificates, + encryption, + ) + + def parse_bytes(self, pkcs12_content): + try: + private_key, certificate, additional_certificates, friendly_name = parse_pkcs12( + pkcs12_content, self.passphrase) + + pkey = None + if private_key is not None: + pkey = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + + crt = None + if certificate is not None: + crt = certificate.public_bytes(serialization.Encoding.PEM) + + other_certs = [] + if additional_certificates is not None: + other_certs = [ + other_cert.public_bytes(serialization.Encoding.PEM) + for other_cert in additional_certificates + ] + + return (pkey, crt, other_certs, friendly_name) + except ValueError as exc: + raise PkcsError(exc) + + # The following methods will get self.pkcs12 passed, which is computed as: + # + # self.pkcs12 = (pkey, cert, self.other_certificates, self.friendly_name) + + def _dump_privatekey(self, pkcs12): + return pkcs12[0].private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) if pkcs12[0] else None + + def _dump_certificate(self, pkcs12): + return pkcs12[1].public_bytes(serialization.Encoding.PEM) if pkcs12[1] else None + + def _dump_other_certificates(self, pkcs12): + return [other_cert.public_bytes(serialization.Encoding.PEM) for other_cert in pkcs12[2]] + + def _get_friendly_name(self, pkcs12): + return pkcs12[3] + + +def select_backend(module, backend): + if backend == 'auto': + # Detection what is possible + can_use_cryptography = CRYPTOGRAPHY_FOUND and CRYPTOGRAPHY_VERSION >= LooseVersion(MINIMAL_CRYPTOGRAPHY_VERSION) + can_use_pyopenssl = PYOPENSSL_FOUND and PYOPENSSL_VERSION >= LooseVersion(MINIMAL_PYOPENSSL_VERSION) + + # If no restrictions are provided, first try cryptography, then pyOpenSSL + if ( + (module.params['iter_size'] is not None and module.params['encryption_level'] != 'compatibility2022') + or module.params['maciter_size'] is not None + ): + # If iter_size (for encryption_level != compatibility2022) or maciter_size is specified, use pyOpenSSL backend + backend = 'pyopenssl' + elif can_use_cryptography: + backend = 'cryptography' + elif can_use_pyopenssl: + backend = 'pyopenssl' + + # Success? + if backend == 'auto': + module.fail_json(msg=("Cannot detect any of the required Python libraries " + "cryptography (>= {0}) or PyOpenSSL (>= {1})").format( + MINIMAL_CRYPTOGRAPHY_VERSION, + MINIMAL_PYOPENSSL_VERSION)) + + if backend == 'pyopenssl': + if not PYOPENSSL_FOUND: + module.fail_json(msg=missing_required_lib('pyOpenSSL >= {0}'.format(MINIMAL_PYOPENSSL_VERSION)), + exception=PYOPENSSL_IMP_ERR) + # module.deprecate('The module is using the PyOpenSSL backend. This backend has been deprecated', + # version='x.0.0', collection_name='community.crypto') + return backend, PkcsPyOpenSSL(module) + elif backend == 'cryptography': + if not CRYPTOGRAPHY_FOUND: + module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)), + exception=CRYPTOGRAPHY_IMP_ERR) + return backend, PkcsCryptography(module) + else: + raise ValueError('Unsupported value for backend: {0}'.format(backend)) + + +def main(): + argument_spec = dict( + action=dict(type='str', default='export', choices=['export', 'parse']), + other_certificates=dict(type='list', elements='path', aliases=['ca_certificates']), + other_certificates_parse_all=dict(type='bool', default=False), + certificate_path=dict(type='path'), + force=dict(type='bool', default=False), + friendly_name=dict(type='str', aliases=['name']), + encryption_level=dict(type='str', choices=['auto', 'compatibility2022'], default='auto'), + iter_size=dict(type='int'), + maciter_size=dict(type='int'), + passphrase=dict(type='str', no_log=True), + path=dict(type='path', required=True), + privatekey_passphrase=dict(type='str', no_log=True), + privatekey_path=dict(type='path'), + privatekey_content=dict(type='str', no_log=True), + state=dict(type='str', default='present', choices=['absent', 'present']), + src=dict(type='path'), + backup=dict(type='bool', default=False), + return_content=dict(type='bool', default=False), + select_crypto_backend=dict(type='str', default='auto', choices=['auto', 'cryptography', 'pyopenssl']), + ) + + required_if = [ + ['action', 'parse', ['src']], + ] + + mutually_exclusive = [ + ['privatekey_path', 'privatekey_content'], + ] + + module = AnsibleModule( + add_file_common_args=True, + argument_spec=argument_spec, + required_if=required_if, + mutually_exclusive=mutually_exclusive, + supports_check_mode=True, + ) + + backend, pkcs12 = select_backend(module, module.params['select_crypto_backend']) + + base_dir = os.path.dirname(module.params['path']) or '.' + if not os.path.isdir(base_dir): + module.fail_json( + name=base_dir, + msg="The directory '%s' does not exist or the path is not a directory" % base_dir + ) + + try: + changed = False + + if module.params['state'] == 'present': + if module.check_mode: + result = pkcs12.dump() + result['changed'] = module.params['force'] or not pkcs12.check(module) + module.exit_json(**result) + + if not pkcs12.check(module, perms_required=False) or module.params['force']: + if module.params['action'] == 'export': + if not module.params['friendly_name']: + module.fail_json(msg='Friendly_name is required') + pkcs12_content = pkcs12.generate_bytes(module) + pkcs12.write(module, pkcs12_content, 0o600) + changed = True + else: + pkey, cert, other_certs, friendly_name = pkcs12.parse() + dump_content = ''.join([to_native(pem) for pem in [pkey, cert] + other_certs if pem is not None]) + pkcs12.write(module, to_bytes(dump_content)) + changed = True + + file_args = module.load_file_common_arguments(module.params) + if module.check_file_absent_if_check_mode(file_args['path']): + changed = True + elif module.set_fs_attributes_if_different(file_args, changed): + changed = True + else: + if module.check_mode: + result = pkcs12.dump() + result['changed'] = os.path.exists(module.params['path']) + module.exit_json(**result) + + if os.path.exists(module.params['path']): + pkcs12.remove(module) + changed = True + + result = pkcs12.dump() + result['changed'] = changed + if os.path.exists(module.params['path']): + file_mode = "%04o" % stat.S_IMODE(os.stat(module.params['path']).st_mode) + result['mode'] = file_mode + + module.exit_json(**result) + except OpenSSLObjectError as exc: + module.fail_json(msg=to_native(exc)) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/crypto/plugins/modules/openssl_privatekey.py b/ansible_collections/community/crypto/plugins/modules/openssl_privatekey.py new file mode 100644 index 00000000..7b50caff --- /dev/null +++ b/ansible_collections/community/crypto/plugins/modules/openssl_privatekey.py @@ -0,0 +1,290 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2016, Yanis Guenane <yanis+ansible@guenane.org> +# 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 = r''' +--- +module: openssl_privatekey +short_description: Generate OpenSSL private keys +description: + - This module allows one to (re)generate OpenSSL private keys. + - The default mode for the private key file will be C(0600) if I(mode) is not explicitly set. +author: + - Yanis Guenane (@Spredzy) + - Felix Fontein (@felixfontein) +extends_documentation_fragment: + - ansible.builtin.files + - community.crypto.attributes + - community.crypto.attributes.files + - community.crypto.module_privatekey +attributes: + check_mode: + support: full + diff_mode: + support: full + safe_file_operations: + support: full +options: + state: + description: + - Whether the private key should exist or not, taking action if the state is different from what is stated. + type: str + default: present + choices: [ absent, present ] + force: + description: + - Should the key be regenerated even if it already exists. + type: bool + default: false + path: + description: + - Name of the file in which the generated TLS/SSL private key will be written. It will have C(0600) mode + if I(mode) is not explicitly set. + type: path + required: true + format: + version_added: '1.0.0' + format_mismatch: + version_added: '1.0.0' + backup: + description: + - Create a backup file including a timestamp so you can get + the original private key back if you overwrote it with a new one by accident. + type: bool + default: false + return_content: + description: + - If set to C(true), will return the (current or generated) private key's content as I(privatekey). + - Note that especially if the private key is not encrypted, you have to make sure that the returned + value is treated appropriately and not accidentally written to logs etc.! Use with care! + - Use Ansible's I(no_log) task option to avoid the output being shown. See also + U(https://docs.ansible.com/ansible/latest/reference_appendices/faq.html#how-do-i-keep-secret-data-in-my-playbook). + type: bool + default: false + version_added: '1.0.0' + regenerate: + version_added: '1.0.0' +seealso: + - module: community.crypto.openssl_privatekey_pipe + - module: community.crypto.openssl_privatekey_info +''' + +EXAMPLES = r''' +- name: Generate an OpenSSL private key with the default values (4096 bits, RSA) + community.crypto.openssl_privatekey: + path: /etc/ssl/private/ansible.com.pem + +- name: Generate an OpenSSL private key with the default values (4096 bits, RSA) and a passphrase + community.crypto.openssl_privatekey: + path: /etc/ssl/private/ansible.com.pem + passphrase: ansible + cipher: auto + +- name: Generate an OpenSSL private key with a different size (2048 bits) + community.crypto.openssl_privatekey: + path: /etc/ssl/private/ansible.com.pem + size: 2048 + +- name: Force regenerate an OpenSSL private key if it already exists + community.crypto.openssl_privatekey: + path: /etc/ssl/private/ansible.com.pem + force: true + +- name: Generate an OpenSSL private key with a different algorithm (DSA) + community.crypto.openssl_privatekey: + path: /etc/ssl/private/ansible.com.pem + type: DSA +''' + +RETURN = r''' +size: + description: Size (in bits) of the TLS/SSL private key. + returned: changed or success + type: int + sample: 4096 +type: + description: Algorithm used to generate the TLS/SSL private key. + returned: changed or success + type: str + sample: RSA +curve: + description: Elliptic curve used to generate the TLS/SSL private key. + returned: changed or success, and I(type) is C(ECC) + type: str + sample: secp256r1 +filename: + description: Path to the generated TLS/SSL private key file. + returned: changed or success + type: str + sample: /etc/ssl/private/ansible.com.pem +fingerprint: + description: + - The fingerprint of the public key. Fingerprint will be generated for each C(hashlib.algorithms) available. + returned: changed or success + type: dict + sample: + md5: "84:75:71:72:8d:04:b5:6c:4d:37:6d:66:83:f5:4c:29" + sha1: "51:cc:7c:68:5d:eb:41:43:88:7e:1a:ae:c7:f8:24:72:ee:71:f6:10" + sha224: "b1:19:a6:6c:14:ac:33:1d:ed:18:50:d3:06:5c:b2:32:91:f1:f1:52:8c:cb:d5:75:e9:f5:9b:46" + sha256: "41:ab:c7:cb:d5:5f:30:60:46:99:ac:d4:00:70:cf:a1:76:4f:24:5d:10:24:57:5d:51:6e:09:97:df:2f:de:c7" + sha384: "85:39:50:4e:de:d9:19:33:40:70:ae:10:ab:59:24:19:51:c3:a2:e4:0b:1c:b1:6e:dd:b3:0c:d9:9e:6a:46:af:da:18:f8:ef:ae:2e:c0:9a:75:2c:9b:b3:0f:3a:5f:3d" + sha512: "fd:ed:5e:39:48:5f:9f:fe:7f:25:06:3f:79:08:cd:ee:a5:e7:b3:3d:13:82:87:1f:84:e1:f5:c7:28:77:53:94:86:56:38:69:f0:d9:35:22:01:1e:a6:60:...:0f:9b" +backup_file: + description: Name of backup file created. + returned: changed and if I(backup) is C(true) + type: str + sample: /path/to/privatekey.pem.2019-03-09@11:22~ +privatekey: + description: + - The (current or generated) private key's content. + - Will be Base64-encoded if the key is in raw format. + returned: if I(state) is C(present) and I(return_content) is C(true) + type: str + version_added: '1.0.0' +''' + +import os + +from ansible.module_utils.common.text.converters import to_native + +from ansible_collections.community.crypto.plugins.module_utils.io import ( + load_file_if_exists, + write_file, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import ( + OpenSSLObjectError, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.support import ( + OpenSSLObject, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.privatekey import ( + select_backend, + get_privatekey_argument_spec, +) + + +class PrivateKeyModule(OpenSSLObject): + + def __init__(self, module, module_backend): + super(PrivateKeyModule, self).__init__( + module.params['path'], + module.params['state'], + module.params['force'], + module.check_mode, + ) + self.module_backend = module_backend + self.return_content = module.params['return_content'] + if self.force: + module_backend.regenerate = 'always' + + self.backup = module.params['backup'] + self.backup_file = None + + if module.params['mode'] is None: + module.params['mode'] = '0600' + + module_backend.set_existing(load_file_if_exists(self.path, module)) + + def generate(self, module): + """Generate a keypair.""" + + if self.module_backend.needs_regeneration(): + # Regenerate + if not self.check_mode: + if self.backup: + self.backup_file = module.backup_local(self.path) + self.module_backend.generate_private_key() + privatekey_data = self.module_backend.get_private_key_data() + if self.return_content: + self.privatekey_bytes = privatekey_data + write_file(module, privatekey_data, 0o600) + self.changed = True + elif self.module_backend.needs_conversion(): + # Convert + if not self.check_mode: + if self.backup: + self.backup_file = module.backup_local(self.path) + self.module_backend.convert_private_key() + privatekey_data = self.module_backend.get_private_key_data() + if self.return_content: + self.privatekey_bytes = privatekey_data + write_file(module, privatekey_data, 0o600) + self.changed = True + + file_args = module.load_file_common_arguments(module.params) + if module.check_file_absent_if_check_mode(file_args['path']): + self.changed = True + else: + self.changed = module.set_fs_attributes_if_different(file_args, self.changed) + + def remove(self, module): + self.module_backend.set_existing(None) + if self.backup and not self.check_mode: + self.backup_file = module.backup_local(self.path) + super(PrivateKeyModule, self).remove(module) + + def dump(self): + """Serialize the object into a dictionary.""" + + result = self.module_backend.dump(include_key=self.return_content) + result['filename'] = self.path + result['changed'] = self.changed + if self.backup_file: + result['backup_file'] = self.backup_file + + return result + + +def main(): + + argument_spec = get_privatekey_argument_spec() + argument_spec.argument_spec.update(dict( + state=dict(type='str', default='present', choices=['present', 'absent']), + force=dict(type='bool', default=False), + path=dict(type='path', required=True), + backup=dict(type='bool', default=False), + return_content=dict(type='bool', default=False), + )) + module = argument_spec.create_ansible_module( + supports_check_mode=True, + add_file_common_args=True, + ) + + base_dir = os.path.dirname(module.params['path']) or '.' + if not os.path.isdir(base_dir): + module.fail_json( + name=base_dir, + msg='The directory %s does not exist or the file is not a directory' % base_dir + ) + + backend, module_backend = select_backend( + module=module, + backend=module.params['select_crypto_backend'], + ) + + try: + private_key = PrivateKeyModule(module, module_backend) + + if private_key.state == 'present': + private_key.generate(module) + else: + private_key.remove(module) + + result = private_key.dump() + module.exit_json(**result) + except OpenSSLObjectError as exc: + module.fail_json(msg=to_native(exc)) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/crypto/plugins/modules/openssl_privatekey_convert.py b/ansible_collections/community/crypto/plugins/modules/openssl_privatekey_convert.py new file mode 100644 index 00000000..5aec5cbe --- /dev/null +++ b/ansible_collections/community/crypto/plugins/modules/openssl_privatekey_convert.py @@ -0,0 +1,171 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2022, 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 = r''' +--- +module: openssl_privatekey_convert +short_description: Convert OpenSSL private keys +version_added: 2.1.0 +description: + - This module allows one to convert OpenSSL private keys. + - The default mode for the private key file will be C(0600) if I(mode) is not explicitly set. +author: + - Felix Fontein (@felixfontein) +extends_documentation_fragment: + - ansible.builtin.files + - community.crypto.attributes + - community.crypto.attributes.files + - community.crypto.module_privatekey_convert +attributes: + check_mode: + support: full + diff_mode: + support: none + safe_file_operations: + support: full +options: + dest_path: + description: + - Name of the file in which the generated TLS/SSL private key will be written. It will have C(0600) mode + if I(mode) is not explicitly set. + type: path + required: true + backup: + description: + - Create a backup file including a timestamp so you can get + the original private key back if you overwrote it with a new one by accident. + type: bool + default: false +seealso: [] +''' + +EXAMPLES = r''' +- name: Convert private key to PKCS8 format with passphrase + community.crypto.openssl_privatekey_convert: + src_path: /etc/ssl/private/ansible.com.pem + dest_path: /etc/ssl/private/ansible.com.key + dest_passphrase: '{{ private_key_passphrase }}' + format: pkcs8 +''' + +RETURN = r''' +backup_file: + description: Name of backup file created. + returned: changed and if I(backup) is C(true) + type: str + sample: /path/to/privatekey.pem.2019-03-09@11:22~ +''' + +import os + +from ansible.module_utils.common.text.converters import to_native + +from ansible_collections.community.crypto.plugins.module_utils.io import ( + load_file_if_exists, + write_file, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import ( + OpenSSLObjectError, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.support import ( + OpenSSLObject, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.privatekey_convert import ( + select_backend, + get_privatekey_argument_spec, +) + + +class PrivateKeyConvertModule(OpenSSLObject): + def __init__(self, module, module_backend): + super(PrivateKeyConvertModule, self).__init__( + module.params['dest_path'], + 'present', + False, + module.check_mode, + ) + self.module_backend = module_backend + + self.backup = module.params['backup'] + self.backup_file = None + + module.params['path'] = module.params['dest_path'] + if module.params['mode'] is None: + module.params['mode'] = '0600' + + module_backend.set_existing_destination(load_file_if_exists(self.path, module)) + + def generate(self, module): + """Do conversion.""" + + if self.module_backend.needs_conversion(): + # Convert + privatekey_data = self.module_backend.get_private_key_data() + if not self.check_mode: + if self.backup: + self.backup_file = module.backup_local(self.path) + write_file(module, privatekey_data, 0o600) + self.changed = True + + file_args = module.load_file_common_arguments(module.params) + if module.check_file_absent_if_check_mode(file_args['path']): + self.changed = True + else: + self.changed = module.set_fs_attributes_if_different(file_args, self.changed) + + def dump(self): + """Serialize the object into a dictionary.""" + + result = self.module_backend.dump() + result['changed'] = self.changed + if self.backup_file: + result['backup_file'] = self.backup_file + + return result + + +def main(): + + argument_spec = get_privatekey_argument_spec() + argument_spec.argument_spec.update(dict( + dest_path=dict(type='path', required=True), + backup=dict(type='bool', default=False), + )) + module = argument_spec.create_ansible_module( + supports_check_mode=True, + add_file_common_args=True, + ) + + base_dir = os.path.dirname(module.params['dest_path']) or '.' + if not os.path.isdir(base_dir): + module.fail_json( + name=base_dir, + msg='The directory %s does not exist or the file is not a directory' % base_dir + ) + + module_backend = select_backend(module=module) + + try: + private_key = PrivateKeyConvertModule(module, module_backend) + + private_key.generate(module) + + result = private_key.dump() + module.exit_json(**result) + except OpenSSLObjectError as exc: + module.fail_json(msg=to_native(exc)) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/crypto/plugins/modules/openssl_privatekey_info.py b/ansible_collections/community/crypto/plugins/modules/openssl_privatekey_info.py new file mode 100644 index 00000000..7eaec234 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/modules/openssl_privatekey_info.py @@ -0,0 +1,278 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2016-2017, Yanis Guenane <yanis+ansible@guenane.org> +# Copyright (c) 2017, Markus Teufelberger <mteufelberger+ansible@mgit.at> +# 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 = r''' +--- +module: openssl_privatekey_info +short_description: Provide information for OpenSSL private keys +description: + - This module allows one to query information on OpenSSL private keys. + - In case the key consistency checks fail, the module will fail as this indicates a faked + private key. In this case, all return variables are still returned. Note that key consistency + checks are not available all key types; if none is available, C(none) is returned for + C(key_is_consistent). + - It uses the cryptography python library to interact with OpenSSL. +requirements: + - cryptography >= 1.2.3 +author: + - Felix Fontein (@felixfontein) + - Yanis Guenane (@Spredzy) +extends_documentation_fragment: + - community.crypto.attributes + - community.crypto.attributes.info_module +options: + path: + description: + - Remote absolute path where the private key file is loaded from. + type: path + content: + description: + - Content of the private key file. + - Either I(path) or I(content) must be specified, but not both. + type: str + version_added: '1.0.0' + passphrase: + description: + - The passphrase for the private key. + type: str + return_private_key_data: + description: + - Whether to return private key data. + - Only set this to C(true) when you want private information about this key to + leave the remote machine. + - "B(WARNING:) you have to make sure that private key data is not accidentally logged!" + type: bool + default: false + check_consistency: + description: + - Whether to check consistency of the private key. + - In community.crypto < 2.0.0, consistency was always checked. + - Since community.crypto 2.0.0, the consistency check has been disabled by default to + avoid private key material to be transported around and computed with, and only do + so when requested explicitly. This can potentially prevent + L(side-channel attacks,https://en.wikipedia.org/wiki/Side-channel_attack). + type: bool + default: false + version_added: 2.0.0 + + select_crypto_backend: + description: + - Determines which crypto backend to use. + - The default choice is C(auto), which tries to use C(cryptography) if available. + - If set to C(cryptography), will try to use the L(cryptography,https://cryptography.io/) library. + type: str + default: auto + choices: [ auto, cryptography ] + +seealso: + - module: community.crypto.openssl_privatekey + - module: community.crypto.openssl_privatekey_pipe + - ref: community.crypto.openssl_privatekey_info filter <ansible_collections.community.crypto.openssl_privatekey_info_filter> + # - plugin: community.crypto.openssl_privatekey_info + # plugin_type: filter + description: A filter variant of this module. +''' + +EXAMPLES = r''' +- name: Generate an OpenSSL private key with the default values (4096 bits, RSA) + community.crypto.openssl_privatekey: + path: /etc/ssl/private/ansible.com.pem + +- name: Get information on generated key + community.crypto.openssl_privatekey_info: + path: /etc/ssl/private/ansible.com.pem + register: result + +- name: Dump information + ansible.builtin.debug: + var: result +''' + +RETURN = r''' +can_load_key: + description: Whether the module was able to load the private key from disk. + returned: always + type: bool +can_parse_key: + description: Whether the module was able to parse the private key. + returned: always + type: bool +key_is_consistent: + description: + - Whether the key is consistent. Can also return C(none) next to C(true) and + C(false), to indicate that consistency could not be checked. + - In case the check returns C(false), the module will fail. + returned: when I(check_consistency=true) + type: bool +public_key: + description: Private key's public key in PEM format. + returned: success + type: str + sample: "-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8A..." +public_key_fingerprints: + description: + - Fingerprints of private key's public key. + - For every hash algorithm available, the fingerprint is computed. + returned: success + type: dict + sample: "{'sha256': 'd4:b3:aa:6d:c8:04:ce:4e:ba:f6:29:4d:92:a3:94:b0:c2:ff:bd:bf:33:63:11:43:34:0f:51:b0:95:09:2f:63', + 'sha512': 'f7:07:4a:f0:b0:f0:e6:8b:95:5f:f9:e6:61:0a:32:68:f1..." +type: + description: + - The key's type. + - One of C(RSA), C(DSA), C(ECC), C(Ed25519), C(X25519), C(Ed448), or C(X448). + - Will start with C(unknown) if the key type cannot be determined. + returned: success + type: str + sample: RSA +public_data: + description: + - Public key data. Depends on key type. + returned: success + type: dict + contains: + size: + description: + - Bit size of modulus (RSA) or prime number (DSA). + type: int + returned: When C(type=RSA) or C(type=DSA) + modulus: + description: + - The RSA key's modulus. + type: int + returned: When C(type=RSA) + exponent: + description: + - The RSA key's public exponent. + type: int + returned: When C(type=RSA) + p: + description: + - The C(p) value for DSA. + - This is the prime modulus upon which arithmetic takes place. + type: int + returned: When C(type=DSA) + q: + description: + - The C(q) value for DSA. + - This is a prime that divides C(p - 1), and at the same time the order of the subgroup of the + multiplicative group of the prime field used. + type: int + returned: When C(type=DSA) + g: + description: + - The C(g) value for DSA. + - This is the element spanning the subgroup of the multiplicative group of the prime field used. + type: int + returned: When C(type=DSA) + curve: + description: + - The curve's name for ECC. + type: str + returned: When C(type=ECC) + exponent_size: + description: + - The maximum number of bits of a private key. This is basically the bit size of the subgroup used. + type: int + returned: When C(type=ECC) + x: + description: + - The C(x) coordinate for the public point on the elliptic curve. + type: int + returned: When C(type=ECC) + y: + description: + - For C(type=ECC), this is the C(y) coordinate for the public point on the elliptic curve. + - For C(type=DSA), this is the publicly known group element whose discrete logarithm w.r.t. C(g) is the private key. + type: int + returned: When C(type=DSA) or C(type=ECC) +private_data: + description: + - Private key data. Depends on key type. + returned: success and when I(return_private_key_data) is set to C(true) + type: dict +''' + + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.common.text.converters import to_native + +from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import ( + OpenSSLObjectError, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.privatekey_info import ( + PrivateKeyConsistencyError, + PrivateKeyParseError, + select_backend, +) + + +def main(): + module = AnsibleModule( + argument_spec=dict( + path=dict(type='path'), + content=dict(type='str', no_log=True), + passphrase=dict(type='str', no_log=True), + return_private_key_data=dict(type='bool', default=False), + check_consistency=dict(type='bool', default=False), + select_crypto_backend=dict(type='str', default='auto', choices=['auto', 'cryptography']), + ), + required_one_of=( + ['path', 'content'], + ), + mutually_exclusive=( + ['path', 'content'], + ), + supports_check_mode=True, + ) + + result = dict( + can_load_key=False, + can_parse_key=False, + key_is_consistent=None, + ) + + if module.params['content'] is not None: + data = module.params['content'].encode('utf-8') + else: + try: + with open(module.params['path'], 'rb') as f: + data = f.read() + except (IOError, OSError) as e: + module.fail_json(msg='Error while reading private key file from disk: {0}'.format(e), **result) + + result['can_load_key'] = True + + backend, module_backend = select_backend( + module, + module.params['select_crypto_backend'], + data, + passphrase=module.params['passphrase'], + return_private_key_data=module.params['return_private_key_data'], + check_consistency=module.params['check_consistency']) + + try: + result.update(module_backend.get_info()) + module.exit_json(**result) + except PrivateKeyParseError as exc: + result.update(exc.result) + module.fail_json(msg=exc.error_message, **result) + except PrivateKeyConsistencyError as exc: + result.update(exc.result) + module.fail_json(msg=exc.error_message, **result) + except OpenSSLObjectError as exc: + module.fail_json(msg=to_native(exc)) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/crypto/plugins/modules/openssl_privatekey_pipe.py b/ansible_collections/community/crypto/plugins/modules/openssl_privatekey_pipe.py new file mode 100644 index 00000000..94fc3826 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/modules/openssl_privatekey_pipe.py @@ -0,0 +1,131 @@ +#!/usr/bin/python +# -*- 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 + + +DOCUMENTATION = r''' +--- +module: openssl_privatekey_pipe +short_description: Generate OpenSSL private keys without disk access +version_added: 1.3.0 +description: + - This module allows one to (re)generate OpenSSL private keys without disk access. + - This allows to read and write keys to vaults without having to write intermediate versions to disk. + - Make sure to not write the result of this module into logs or to the console, as it contains private key data! Use the I(no_log) task option to be sure. + - Note that this module is implemented as an L(action plugin,https://docs.ansible.com/ansible/latest/plugins/action.html) + and will always be executed on the controller. +author: + - Yanis Guenane (@Spredzy) + - Felix Fontein (@felixfontein) +extends_documentation_fragment: + - community.crypto.attributes + - community.crypto.attributes.flow + - community.crypto.module_privatekey +attributes: + action: + support: full + async: + support: none + details: + - This action runs completely on the controller. + check_mode: + support: full + diff_mode: + support: full +options: + content: + description: + - The current private key data. + - Needed for idempotency. If not provided, the module will always return a change, and all idempotence-related + options are ignored. + type: str + content_base64: + description: + - Set to C(true) if the content is base64 encoded. + type: bool + default: false + return_current_key: + description: + - Set to C(true) to return the current private key when the module did not generate a new one. + - Note that in case of check mode, when this option is not set to C(true), the module always returns the + current key (if it was provided) and Ansible will replace it by C(VALUE_SPECIFIED_IN_NO_LOG_PARAMETER). + type: bool + default: false +seealso: + - module: community.crypto.openssl_privatekey + - module: community.crypto.openssl_privatekey_info +''' + +EXAMPLES = r''' +- name: Generate an OpenSSL private key with the default values (4096 bits, RSA) + community.crypto.openssl_privatekey_pipe: + path: /etc/ssl/private/ansible.com.pem + register: output + no_log: true # make sure that private key data is not accidentally revealed in logs! +- name: Show generated key + debug: + msg: "{{ output.privatekey }}" + # DO NOT OUTPUT KEY MATERIAL TO CONSOLE OR LOGS IN PRODUCTION! + +- block: + - name: Update sops-encrypted key with the community.sops collection + community.crypto.openssl_privatekey_pipe: + content: "{{ lookup('community.sops.sops', 'private_key.pem.sops') }}" + size: 2048 + register: output + no_log: true # make sure that private key data is not accidentally revealed in logs! + + - name: Update encrypted key when openssl_privatekey_pipe reported a change + community.sops.sops_encrypt: + path: private_key.pem.sops + content_text: "{{ output.privatekey }}" + when: output is changed + always: + - name: Make sure that output (which contains the private key) is overwritten + set_fact: + output: '' +''' + +RETURN = r''' +size: + description: Size (in bits) of the TLS/SSL private key. + returned: changed or success + type: int + sample: 4096 +type: + description: Algorithm used to generate the TLS/SSL private key. + returned: changed or success + type: str + sample: RSA +curve: + description: Elliptic curve used to generate the TLS/SSL private key. + returned: changed or success, and I(type) is C(ECC) + type: str + sample: secp256r1 +fingerprint: + description: + - The fingerprint of the public key. Fingerprint will be generated for each C(hashlib.algorithms) available. + returned: changed or success + type: dict + sample: + md5: "84:75:71:72:8d:04:b5:6c:4d:37:6d:66:83:f5:4c:29" + sha1: "51:cc:7c:68:5d:eb:41:43:88:7e:1a:ae:c7:f8:24:72:ee:71:f6:10" + sha224: "b1:19:a6:6c:14:ac:33:1d:ed:18:50:d3:06:5c:b2:32:91:f1:f1:52:8c:cb:d5:75:e9:f5:9b:46" + sha256: "41:ab:c7:cb:d5:5f:30:60:46:99:ac:d4:00:70:cf:a1:76:4f:24:5d:10:24:57:5d:51:6e:09:97:df:2f:de:c7" + sha384: "85:39:50:4e:de:d9:19:33:40:70:ae:10:ab:59:24:19:51:c3:a2:e4:0b:1c:b1:6e:dd:b3:0c:d9:9e:6a:46:af:da:18:f8:ef:ae:2e:c0:9a:75:2c:9b:b3:0f:3a:5f:3d" + sha512: "fd:ed:5e:39:48:5f:9f:fe:7f:25:06:3f:79:08:cd:ee:a5:e7:b3:3d:13:82:87:1f:84:e1:f5:c7:28:77:53:94:86:56:38:69:f0:d9:35:22:01:1e:a6:60:...:0f:9b" +privatekey: + description: + - The generated private key's content. + - Please note that if the result is not changed, the current private key will only be returned + if the I(return_current_key) option is set to C(true). + - Will be Base64-encoded if the key is in raw format. + returned: changed, or I(return_current_key) is C(true) + type: str +''' diff --git a/ansible_collections/community/crypto/plugins/modules/openssl_publickey.py b/ansible_collections/community/crypto/plugins/modules/openssl_publickey.py new file mode 100644 index 00000000..da01d1fb --- /dev/null +++ b/ansible_collections/community/crypto/plugins/modules/openssl_publickey.py @@ -0,0 +1,488 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2016, Yanis Guenane <yanis+ansible@guenane.org> +# 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 = r''' +--- +module: openssl_publickey +short_description: Generate an OpenSSL public key from its private key. +description: + - This module allows one to (re)generate public keys from their private keys. + - Public keys are generated in PEM or OpenSSH format. Private keys must be OpenSSL PEM keys. + OpenSSH private keys are not supported, use the M(community.crypto.openssh_keypair) module to manage these. + - The module uses the cryptography Python library. +requirements: + - cryptography >= 1.2.3 (older versions might work as well) + - Needs cryptography >= 1.4 if I(format) is C(OpenSSH) +author: + - Yanis Guenane (@Spredzy) + - Felix Fontein (@felixfontein) +extends_documentation_fragment: + - ansible.builtin.files + - community.crypto.attributes + - community.crypto.attributes.files +attributes: + check_mode: + support: full + diff_mode: + support: full + safe_file_operations: + support: full +options: + state: + description: + - Whether the public key should exist or not, taking action if the state is different from what is stated. + type: str + default: present + choices: [ absent, present ] + force: + description: + - Should the key be regenerated even it it already exists. + type: bool + default: false + format: + description: + - The format of the public key. + type: str + default: PEM + choices: [ OpenSSH, PEM ] + path: + description: + - Name of the file in which the generated TLS/SSL public key will be written. + type: path + required: true + privatekey_path: + description: + - Path to the TLS/SSL private key from which to generate the public key. + - Either I(privatekey_path) or I(privatekey_content) must be specified, but not both. + If I(state) is C(present), one of them is required. + type: path + privatekey_content: + description: + - The content of the TLS/SSL private key from which to generate the public key. + - Either I(privatekey_path) or I(privatekey_content) must be specified, but not both. + If I(state) is C(present), one of them is required. + type: str + version_added: '1.0.0' + privatekey_passphrase: + description: + - The passphrase for the private key. + type: str + backup: + description: + - Create a backup file including a timestamp so you can get the original + public key back if you overwrote it with a different one by accident. + type: bool + default: false + select_crypto_backend: + description: + - Determines which crypto backend to use. + - The default choice is C(auto), which tries to use C(cryptography) if available. + - If set to C(cryptography), will try to use the L(cryptography,https://cryptography.io/) library. + type: str + default: auto + choices: [ auto, cryptography ] + return_content: + description: + - If set to C(true), will return the (current or generated) public key's content as I(publickey). + type: bool + default: false + version_added: '1.0.0' +seealso: + - module: community.crypto.x509_certificate + - module: community.crypto.x509_certificate_pipe + - module: community.crypto.openssl_csr + - module: community.crypto.openssl_csr_pipe + - module: community.crypto.openssl_dhparam + - module: community.crypto.openssl_pkcs12 + - module: community.crypto.openssl_privatekey + - module: community.crypto.openssl_privatekey_pipe +''' + +EXAMPLES = r''' +- name: Generate an OpenSSL public key in PEM format + community.crypto.openssl_publickey: + path: /etc/ssl/public/ansible.com.pem + privatekey_path: /etc/ssl/private/ansible.com.pem + +- name: Generate an OpenSSL public key in PEM format from an inline key + community.crypto.openssl_publickey: + path: /etc/ssl/public/ansible.com.pem + privatekey_content: "{{ private_key_content }}" + +- name: Generate an OpenSSL public key in OpenSSH v2 format + community.crypto.openssl_publickey: + path: /etc/ssl/public/ansible.com.pem + privatekey_path: /etc/ssl/private/ansible.com.pem + format: OpenSSH + +- name: Generate an OpenSSL public key with a passphrase protected private key + community.crypto.openssl_publickey: + path: /etc/ssl/public/ansible.com.pem + privatekey_path: /etc/ssl/private/ansible.com.pem + privatekey_passphrase: ansible + +- name: Force regenerate an OpenSSL public key if it already exists + community.crypto.openssl_publickey: + path: /etc/ssl/public/ansible.com.pem + privatekey_path: /etc/ssl/private/ansible.com.pem + force: true + +- name: Remove an OpenSSL public key + community.crypto.openssl_publickey: + path: /etc/ssl/public/ansible.com.pem + state: absent +''' + +RETURN = r''' +privatekey: + description: + - Path to the TLS/SSL private key the public key was generated from. + - Will be C(none) if the private key has been provided in I(privatekey_content). + returned: changed or success + type: str + sample: /etc/ssl/private/ansible.com.pem +format: + description: The format of the public key (PEM, OpenSSH, ...). + returned: changed or success + type: str + sample: PEM +filename: + description: Path to the generated TLS/SSL public key file. + returned: changed or success + type: str + sample: /etc/ssl/public/ansible.com.pem +fingerprint: + description: + - The fingerprint of the public key. Fingerprint will be generated for each hashlib.algorithms available. + returned: changed or success + type: dict + sample: + md5: "84:75:71:72:8d:04:b5:6c:4d:37:6d:66:83:f5:4c:29" + sha1: "51:cc:7c:68:5d:eb:41:43:88:7e:1a:ae:c7:f8:24:72:ee:71:f6:10" + sha224: "b1:19:a6:6c:14:ac:33:1d:ed:18:50:d3:06:5c:b2:32:91:f1:f1:52:8c:cb:d5:75:e9:f5:9b:46" + sha256: "41:ab:c7:cb:d5:5f:30:60:46:99:ac:d4:00:70:cf:a1:76:4f:24:5d:10:24:57:5d:51:6e:09:97:df:2f:de:c7" + sha384: "85:39:50:4e:de:d9:19:33:40:70:ae:10:ab:59:24:19:51:c3:a2:e4:0b:1c:b1:6e:dd:b3:0c:d9:9e:6a:46:af:da:18:f8:ef:ae:2e:c0:9a:75:2c:9b:b3:0f:3a:5f:3d" + sha512: "fd:ed:5e:39:48:5f:9f:fe:7f:25:06:3f:79:08:cd:ee:a5:e7:b3:3d:13:82:87:1f:84:e1:f5:c7:28:77:53:94:86:56:38:69:f0:d9:35:22:01:1e:a6:60:...:0f:9b" +backup_file: + description: Name of backup file created. + returned: changed and if I(backup) is C(true) + type: str + sample: /path/to/publickey.pem.2019-03-09@11:22~ +publickey: + description: The (current or generated) public key's content. + returned: if I(state) is C(present) and I(return_content) is C(true) + type: str + version_added: '1.0.0' +''' + +import os +import traceback + +from ansible.module_utils.basic import AnsibleModule, missing_required_lib +from ansible.module_utils.common.text.converters import to_native + +from ansible_collections.community.crypto.plugins.module_utils.version import LooseVersion + +from ansible_collections.community.crypto.plugins.module_utils.io import ( + load_file_if_exists, + write_file, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import ( + OpenSSLObjectError, + OpenSSLBadPassphraseError, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.support import ( + OpenSSLObject, + load_privatekey, + get_fingerprint, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.publickey_info import ( + PublicKeyParseError, + get_publickey_info, +) + +MINIMAL_CRYPTOGRAPHY_VERSION = '1.2.3' +MINIMAL_CRYPTOGRAPHY_VERSION_OPENSSH = '1.4' + +CRYPTOGRAPHY_IMP_ERR = None +try: + import cryptography + from cryptography.hazmat.backends import default_backend + from cryptography.hazmat.primitives import serialization as crypto_serialization + CRYPTOGRAPHY_VERSION = LooseVersion(cryptography.__version__) +except ImportError: + CRYPTOGRAPHY_IMP_ERR = traceback.format_exc() + CRYPTOGRAPHY_FOUND = False +else: + CRYPTOGRAPHY_FOUND = True + + +class PublicKeyError(OpenSSLObjectError): + pass + + +class PublicKey(OpenSSLObject): + + def __init__(self, module, backend): + super(PublicKey, self).__init__( + module.params['path'], + module.params['state'], + module.params['force'], + module.check_mode + ) + self.module = module + self.format = module.params['format'] + self.privatekey_path = module.params['privatekey_path'] + self.privatekey_content = module.params['privatekey_content'] + if self.privatekey_content is not None: + self.privatekey_content = self.privatekey_content.encode('utf-8') + self.privatekey_passphrase = module.params['privatekey_passphrase'] + self.privatekey = None + self.publickey_bytes = None + self.return_content = module.params['return_content'] + self.fingerprint = {} + self.backend = backend + + self.backup = module.params['backup'] + self.backup_file = None + + self.diff_before = self._get_info(None) + self.diff_after = self._get_info(None) + + def _get_info(self, data): + if data is None: + return dict() + result = dict(can_parse_key=False) + try: + result.update(get_publickey_info( + self.module, self.backend, content=data, prefer_one_fingerprint=True)) + result['can_parse_key'] = True + except PublicKeyParseError as exc: + result.update(exc.result) + except Exception as exc: + pass + return result + + def _create_publickey(self, module): + self.privatekey = load_privatekey( + path=self.privatekey_path, + content=self.privatekey_content, + passphrase=self.privatekey_passphrase, + backend=self.backend + ) + if self.backend == 'cryptography': + if self.format == 'OpenSSH': + return self.privatekey.public_key().public_bytes( + crypto_serialization.Encoding.OpenSSH, + crypto_serialization.PublicFormat.OpenSSH + ) + else: + return self.privatekey.public_key().public_bytes( + crypto_serialization.Encoding.PEM, + crypto_serialization.PublicFormat.SubjectPublicKeyInfo + ) + + def generate(self, module): + """Generate the public key.""" + + if self.privatekey_content is None and not os.path.exists(self.privatekey_path): + raise PublicKeyError( + 'The private key %s does not exist' % self.privatekey_path + ) + + if not self.check(module, perms_required=False) or self.force: + try: + publickey_content = self._create_publickey(module) + self.diff_after = self._get_info(publickey_content) + if self.return_content: + self.publickey_bytes = publickey_content + + if self.backup: + self.backup_file = module.backup_local(self.path) + write_file(module, publickey_content) + + self.changed = True + except OpenSSLBadPassphraseError as exc: + raise PublicKeyError(exc) + except (IOError, OSError) as exc: + raise PublicKeyError(exc) + + self.fingerprint = get_fingerprint( + path=self.privatekey_path, + content=self.privatekey_content, + passphrase=self.privatekey_passphrase, + backend=self.backend, + ) + file_args = module.load_file_common_arguments(module.params) + if module.check_file_absent_if_check_mode(file_args['path']): + self.changed = True + elif module.set_fs_attributes_if_different(file_args, False): + self.changed = True + + def check(self, module, perms_required=True): + """Ensure the resource is in its desired state.""" + + state_and_perms = super(PublicKey, self).check(module, perms_required) + + def _check_privatekey(): + if self.privatekey_content is None and not os.path.exists(self.privatekey_path): + return False + + try: + with open(self.path, 'rb') as public_key_fh: + publickey_content = public_key_fh.read() + self.diff_before = self.diff_after = self._get_info(publickey_content) + if self.return_content: + self.publickey_bytes = publickey_content + if self.backend == 'cryptography': + if self.format == 'OpenSSH': + # Read and dump public key. Makes sure that the comment is stripped off. + current_publickey = crypto_serialization.load_ssh_public_key(publickey_content, backend=default_backend()) + publickey_content = current_publickey.public_bytes( + crypto_serialization.Encoding.OpenSSH, + crypto_serialization.PublicFormat.OpenSSH + ) + else: + current_publickey = crypto_serialization.load_pem_public_key(publickey_content, backend=default_backend()) + publickey_content = current_publickey.public_bytes( + crypto_serialization.Encoding.PEM, + crypto_serialization.PublicFormat.SubjectPublicKeyInfo + ) + except Exception as dummy: + return False + + try: + desired_publickey = self._create_publickey(module) + except OpenSSLBadPassphraseError as exc: + raise PublicKeyError(exc) + + return publickey_content == desired_publickey + + if not state_and_perms: + return state_and_perms + + return _check_privatekey() + + def remove(self, module): + if self.backup: + self.backup_file = module.backup_local(self.path) + super(PublicKey, self).remove(module) + + def dump(self): + """Serialize the object into a dictionary.""" + + result = { + 'privatekey': self.privatekey_path, + 'filename': self.path, + 'format': self.format, + 'changed': self.changed, + 'fingerprint': self.fingerprint, + } + if self.backup_file: + result['backup_file'] = self.backup_file + if self.return_content: + if self.publickey_bytes is None: + self.publickey_bytes = load_file_if_exists(self.path, ignore_errors=True) + result['publickey'] = self.publickey_bytes.decode('utf-8') if self.publickey_bytes else None + + result['diff'] = dict( + before=self.diff_before, + after=self.diff_after, + ) + + return result + + +def main(): + + module = AnsibleModule( + argument_spec=dict( + state=dict(type='str', default='present', choices=['present', 'absent']), + force=dict(type='bool', default=False), + path=dict(type='path', required=True), + privatekey_path=dict(type='path'), + privatekey_content=dict(type='str', no_log=True), + format=dict(type='str', default='PEM', choices=['OpenSSH', 'PEM']), + privatekey_passphrase=dict(type='str', no_log=True), + backup=dict(type='bool', default=False), + select_crypto_backend=dict(type='str', choices=['auto', 'cryptography'], default='auto'), + return_content=dict(type='bool', default=False), + ), + supports_check_mode=True, + add_file_common_args=True, + required_if=[('state', 'present', ['privatekey_path', 'privatekey_content'], True)], + mutually_exclusive=( + ['privatekey_path', 'privatekey_content'], + ), + ) + + minimal_cryptography_version = MINIMAL_CRYPTOGRAPHY_VERSION + if module.params['format'] == 'OpenSSH': + minimal_cryptography_version = MINIMAL_CRYPTOGRAPHY_VERSION_OPENSSH + + backend = module.params['select_crypto_backend'] + if backend == 'auto': + # Detection what is possible + can_use_cryptography = CRYPTOGRAPHY_FOUND and CRYPTOGRAPHY_VERSION >= LooseVersion(minimal_cryptography_version) + + # Decision + if can_use_cryptography: + backend = 'cryptography' + + # Success? + if backend == 'auto': + module.fail_json(msg=("Cannot detect the required Python library " + "cryptography (>= {0})").format(minimal_cryptography_version)) + + if module.params['format'] == 'OpenSSH' and backend != 'cryptography': + module.fail_json(msg="Format OpenSSH requires the cryptography backend.") + + if backend == 'cryptography': + if not CRYPTOGRAPHY_FOUND: + module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(minimal_cryptography_version)), + exception=CRYPTOGRAPHY_IMP_ERR) + + base_dir = os.path.dirname(module.params['path']) or '.' + if not os.path.isdir(base_dir): + module.fail_json( + name=base_dir, + msg="The directory '%s' does not exist or the file is not a directory" % base_dir + ) + + try: + public_key = PublicKey(module, backend) + + if public_key.state == 'present': + if module.check_mode: + result = public_key.dump() + result['changed'] = module.params['force'] or not public_key.check(module) + module.exit_json(**result) + + public_key.generate(module) + else: + if module.check_mode: + result = public_key.dump() + result['changed'] = os.path.exists(module.params['path']) + module.exit_json(**result) + + public_key.remove(module) + + result = public_key.dump() + module.exit_json(**result) + except OpenSSLObjectError as exc: + module.fail_json(msg=to_native(exc)) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/crypto/plugins/modules/openssl_publickey_info.py b/ansible_collections/community/crypto/plugins/modules/openssl_publickey_info.py new file mode 100644 index 00000000..7b061006 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/modules/openssl_publickey_info.py @@ -0,0 +1,217 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2021, 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 = r''' +--- +module: openssl_publickey_info +short_description: Provide information for OpenSSL public keys +description: + - This module allows one to query information on OpenSSL public keys. + - It uses the cryptography python library to interact with OpenSSL. +version_added: 1.7.0 +requirements: + - cryptography >= 1.2.3 +author: + - Felix Fontein (@felixfontein) +extends_documentation_fragment: + - community.crypto.attributes + - community.crypto.attributes.info_module +options: + path: + description: + - Remote absolute path where the public key file is loaded from. + type: path + content: + description: + - Content of the public key file. + - Either I(path) or I(content) must be specified, but not both. + type: str + + select_crypto_backend: + description: + - Determines which crypto backend to use. + - The default choice is C(auto), which tries to use C(cryptography) if available. + - If set to C(cryptography), will try to use the L(cryptography,https://cryptography.io/) library. + type: str + default: auto + choices: [ auto, cryptography ] + +seealso: + - module: community.crypto.openssl_publickey + - module: community.crypto.openssl_privatekey_info + - ref: community.crypto.openssl_publickey_info filter <ansible_collections.community.crypto.openssl_publickey_info_filter> + # - plugin: community.crypto.openssl_publickey_info + # plugin_type: filter + description: A filter variant of this module. +''' + +EXAMPLES = r''' +- name: Generate an OpenSSL private key with the default values (4096 bits, RSA) + community.crypto.openssl_privatekey: + path: /etc/ssl/private/ansible.com.pem + +- name: Create public key from private key + community.crypto.openssl_publickey: + privatekey_path: /etc/ssl/private/ansible.com.pem + path: /etc/ssl/ansible.com.pub + +- name: Get information on public key + community.crypto.openssl_publickey_info: + path: /etc/ssl/ansible.com.pub + register: result + +- name: Dump information + ansible.builtin.debug: + var: result +''' + +RETURN = r''' +fingerprints: + description: + - Fingerprints of public key. + - For every hash algorithm available, the fingerprint is computed. + returned: success + type: dict + sample: "{'sha256': 'd4:b3:aa:6d:c8:04:ce:4e:ba:f6:29:4d:92:a3:94:b0:c2:ff:bd:bf:33:63:11:43:34:0f:51:b0:95:09:2f:63', + 'sha512': 'f7:07:4a:f0:b0:f0:e6:8b:95:5f:f9:e6:61:0a:32:68:f1..." +type: + description: + - The key's type. + - One of C(RSA), C(DSA), C(ECC), C(Ed25519), C(X25519), C(Ed448), or C(X448). + - Will start with C(unknown) if the key type cannot be determined. + returned: success + type: str + sample: RSA +public_data: + description: + - Public key data. Depends on key type. + returned: success + type: dict + contains: + size: + description: + - Bit size of modulus (RSA) or prime number (DSA). + type: int + returned: When C(type=RSA) or C(type=DSA) + modulus: + description: + - The RSA key's modulus. + type: int + returned: When C(type=RSA) + exponent: + description: + - The RSA key's public exponent. + type: int + returned: When C(type=RSA) + p: + description: + - The C(p) value for DSA. + - This is the prime modulus upon which arithmetic takes place. + type: int + returned: When C(type=DSA) + q: + description: + - The C(q) value for DSA. + - This is a prime that divides C(p - 1), and at the same time the order of the subgroup of the + multiplicative group of the prime field used. + type: int + returned: When C(type=DSA) + g: + description: + - The C(g) value for DSA. + - This is the element spanning the subgroup of the multiplicative group of the prime field used. + type: int + returned: When C(type=DSA) + curve: + description: + - The curve's name for ECC. + type: str + returned: When C(type=ECC) + exponent_size: + description: + - The maximum number of bits of a private key. This is basically the bit size of the subgroup used. + type: int + returned: When C(type=ECC) + x: + description: + - The C(x) coordinate for the public point on the elliptic curve. + type: int + returned: When C(type=ECC) + y: + description: + - For C(type=ECC), this is the C(y) coordinate for the public point on the elliptic curve. + - For C(type=DSA), this is the publicly known group element whose discrete logarithm w.r.t. C(g) is the private key. + type: int + returned: When C(type=DSA) or C(type=ECC) +''' + + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.common.text.converters import to_native + +from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import ( + OpenSSLObjectError, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.publickey_info import ( + PublicKeyParseError, + select_backend, +) + + +def main(): + module = AnsibleModule( + argument_spec=dict( + path=dict(type='path'), + content=dict(type='str', no_log=True), + select_crypto_backend=dict(type='str', default='auto', choices=['auto', 'cryptography']), + ), + required_one_of=( + ['path', 'content'], + ), + mutually_exclusive=( + ['path', 'content'], + ), + supports_check_mode=True, + ) + + result = dict( + can_load_key=False, + can_parse_key=False, + key_is_consistent=None, + ) + + if module.params['content'] is not None: + data = module.params['content'].encode('utf-8') + else: + try: + with open(module.params['path'], 'rb') as f: + data = f.read() + except (IOError, OSError) as e: + module.fail_json(msg='Error while reading public key file from disk: {0}'.format(e), **result) + + backend, module_backend = select_backend( + module, + module.params['select_crypto_backend'], + data) + + try: + result.update(module_backend.get_info()) + module.exit_json(**result) + except PublicKeyParseError as exc: + result.update(exc.result) + module.fail_json(msg=exc.error_message, **result) + except OpenSSLObjectError as exc: + module.fail_json(msg=to_native(exc)) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/crypto/plugins/modules/openssl_signature.py b/ansible_collections/community/crypto/plugins/modules/openssl_signature.py new file mode 100644 index 00000000..363a0553 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/modules/openssl_signature.py @@ -0,0 +1,276 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2019, Patrick Pichler <ppichler+ansible@mgit.at> +# 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 = r''' +--- +module: openssl_signature +version_added: 1.1.0 +short_description: Sign data with openssl +description: + - This module allows one to sign data using a private key. + - The module uses the cryptography Python library. +requirements: + - cryptography >= 1.4 (some key types require newer versions) +author: + - Patrick Pichler (@aveexy) + - Markus Teufelberger (@MarkusTeufelberger) +extends_documentation_fragment: + - community.crypto.attributes +attributes: + check_mode: + support: full + details: + - This action does not modify state. + diff_mode: + support: none +options: + privatekey_path: + description: + - The path to the private key to use when signing. + - Either I(privatekey_path) or I(privatekey_content) must be specified, but not both. + type: path + privatekey_content: + description: + - The content of the private key to use when signing the certificate signing request. + - Either I(privatekey_path) or I(privatekey_content) must be specified, but not both. + type: str + privatekey_passphrase: + description: + - The passphrase for the private key. + - This is required if the private key is password protected. + type: str + path: + description: + - The file to sign. + - This file will only be read and not modified. + type: path + required: true + select_crypto_backend: + description: + - Determines which crypto backend to use. + - The default choice is C(auto), which tries to use C(cryptography) if available. + - If set to C(cryptography), will try to use the L(cryptography,https://cryptography.io/) library. + type: str + default: auto + choices: [ auto, cryptography ] +notes: + - | + When using the C(cryptography) backend, the following key types require at least the following C(cryptography) version: + RSA keys: C(cryptography) >= 1.4 + DSA and ECDSA keys: C(cryptography) >= 1.5 + ed448 and ed25519 keys: C(cryptography) >= 2.6 +seealso: + - module: community.crypto.openssl_signature_info + - module: community.crypto.openssl_privatekey +''' + +EXAMPLES = r''' +- name: Sign example file + community.crypto.openssl_signature: + privatekey_path: private.key + path: /tmp/example_file + register: sig + +- name: Verify signature of example file + community.crypto.openssl_signature_info: + certificate_path: cert.pem + path: /tmp/example_file + signature: "{{ sig.signature }}" + register: verify + +- name: Make sure the signature is valid + assert: + that: + - verify.valid +''' + +RETURN = r''' +signature: + description: Base64 encoded signature. + returned: success + type: str +''' + +import os +import traceback +import base64 + +from ansible_collections.community.crypto.plugins.module_utils.version import LooseVersion + +MINIMAL_CRYPTOGRAPHY_VERSION = '1.4' + +CRYPTOGRAPHY_IMP_ERR = None +try: + import cryptography + import cryptography.hazmat.primitives.asymmetric.padding + import cryptography.hazmat.primitives.hashes + CRYPTOGRAPHY_VERSION = LooseVersion(cryptography.__version__) +except ImportError: + CRYPTOGRAPHY_IMP_ERR = traceback.format_exc() + CRYPTOGRAPHY_FOUND = False +else: + CRYPTOGRAPHY_FOUND = True + +from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import ( + CRYPTOGRAPHY_HAS_DSA_SIGN, + CRYPTOGRAPHY_HAS_EC_SIGN, + CRYPTOGRAPHY_HAS_ED25519_SIGN, + CRYPTOGRAPHY_HAS_ED448_SIGN, + CRYPTOGRAPHY_HAS_RSA_SIGN, + OpenSSLObjectError, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.support import ( + OpenSSLObject, + load_privatekey, +) + +from ansible.module_utils.common.text.converters import to_native +from ansible.module_utils.basic import AnsibleModule, missing_required_lib + + +class SignatureBase(OpenSSLObject): + + def __init__(self, module, backend): + super(SignatureBase, self).__init__( + path=module.params['path'], + state='present', + force=False, + check_mode=module.check_mode + ) + + self.backend = backend + + self.privatekey_path = module.params['privatekey_path'] + self.privatekey_content = module.params['privatekey_content'] + if self.privatekey_content is not None: + self.privatekey_content = self.privatekey_content.encode('utf-8') + self.privatekey_passphrase = module.params['privatekey_passphrase'] + + def generate(self): + # Empty method because OpenSSLObject wants this + pass + + def dump(self): + # Empty method because OpenSSLObject wants this + pass + + +# Implementation with using cryptography +class SignatureCryptography(SignatureBase): + + def __init__(self, module, backend): + super(SignatureCryptography, self).__init__(module, backend) + + def run(self): + _padding = cryptography.hazmat.primitives.asymmetric.padding.PKCS1v15() + _hash = cryptography.hazmat.primitives.hashes.SHA256() + + result = dict() + + try: + with open(self.path, "rb") as f: + _in = f.read() + + private_key = load_privatekey( + path=self.privatekey_path, + content=self.privatekey_content, + passphrase=self.privatekey_passphrase, + backend=self.backend, + ) + + signature = None + + if CRYPTOGRAPHY_HAS_DSA_SIGN: + if isinstance(private_key, cryptography.hazmat.primitives.asymmetric.dsa.DSAPrivateKey): + signature = private_key.sign(_in, _hash) + + if CRYPTOGRAPHY_HAS_EC_SIGN: + if isinstance(private_key, cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey): + signature = private_key.sign(_in, cryptography.hazmat.primitives.asymmetric.ec.ECDSA(_hash)) + + if CRYPTOGRAPHY_HAS_ED25519_SIGN: + if isinstance(private_key, cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey): + signature = private_key.sign(_in) + + if CRYPTOGRAPHY_HAS_ED448_SIGN: + if isinstance(private_key, cryptography.hazmat.primitives.asymmetric.ed448.Ed448PrivateKey): + signature = private_key.sign(_in) + + if CRYPTOGRAPHY_HAS_RSA_SIGN: + if isinstance(private_key, cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey): + signature = private_key.sign(_in, _padding, _hash) + + if signature is None: + self.module.fail_json( + msg="Unsupported key type. Your cryptography version is {0}".format(CRYPTOGRAPHY_VERSION) + ) + + result['signature'] = base64.b64encode(signature) + return result + + except Exception as e: + raise OpenSSLObjectError(e) + + +def main(): + module = AnsibleModule( + argument_spec=dict( + privatekey_path=dict(type='path'), + privatekey_content=dict(type='str', no_log=True), + privatekey_passphrase=dict(type='str', no_log=True), + path=dict(type='path', required=True), + select_crypto_backend=dict(type='str', choices=['auto', 'cryptography'], default='auto'), + ), + mutually_exclusive=( + ['privatekey_path', 'privatekey_content'], + ), + required_one_of=( + ['privatekey_path', 'privatekey_content'], + ), + supports_check_mode=True, + ) + + if not os.path.isfile(module.params['path']): + module.fail_json( + name=module.params['path'], + msg='The file {0} does not exist'.format(module.params['path']) + ) + + backend = module.params['select_crypto_backend'] + if backend == 'auto': + # Detection what is possible + can_use_cryptography = CRYPTOGRAPHY_FOUND and CRYPTOGRAPHY_VERSION >= LooseVersion(MINIMAL_CRYPTOGRAPHY_VERSION) + + # Decision + if can_use_cryptography: + backend = 'cryptography' + + # Success? + if backend == 'auto': + module.fail_json(msg=("Cannot detect the required Python library " + "cryptography (>= {0})").format(MINIMAL_CRYPTOGRAPHY_VERSION)) + try: + if backend == 'cryptography': + if not CRYPTOGRAPHY_FOUND: + module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)), + exception=CRYPTOGRAPHY_IMP_ERR) + _sign = SignatureCryptography(module, backend) + + result = _sign.run() + + module.exit_json(**result) + except OpenSSLObjectError as exc: + module.fail_json(msg=to_native(exc)) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/crypto/plugins/modules/openssl_signature_info.py b/ansible_collections/community/crypto/plugins/modules/openssl_signature_info.py new file mode 100644 index 00000000..508a47c0 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/modules/openssl_signature_info.py @@ -0,0 +1,299 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2019, Patrick Pichler <ppichler+ansible@mgit.at> +# 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 = r''' +--- +module: openssl_signature_info +version_added: 1.1.0 +short_description: Verify signatures with openssl +description: + - This module allows one to verify a signature for a file by a certificate. + - The module uses the cryptography Python library. +requirements: + - cryptography >= 1.4 (some key types require newer versions) +author: + - Patrick Pichler (@aveexy) + - Markus Teufelberger (@MarkusTeufelberger) +extends_documentation_fragment: + - community.crypto.attributes + - community.crypto.attributes.info_module +options: + path: + description: + - The signed file to verify. + - This file will only be read and not modified. + type: path + required: true + certificate_path: + description: + - The path to the certificate used to verify the signature. + - Either I(certificate_path) or I(certificate_content) must be specified, but not both. + type: path + certificate_content: + description: + - The content of the certificate used to verify the signature. + - Either I(certificate_path) or I(certificate_content) must be specified, but not both. + type: str + signature: + description: Base64 encoded signature. + type: str + required: true + select_crypto_backend: + description: + - Determines which crypto backend to use. + - The default choice is C(auto), which tries to use C(cryptography) if available. + - If set to C(cryptography), will try to use the L(cryptography,https://cryptography.io/) library. + type: str + default: auto + choices: [ auto, cryptography ] +notes: + - | + When using the C(cryptography) backend, the following key types require at least the following C(cryptography) version: + RSA keys: C(cryptography) >= 1.4 + DSA and ECDSA keys: C(cryptography) >= 1.5 + ed448 and ed25519 keys: C(cryptography) >= 2.6 +seealso: + - module: community.crypto.openssl_signature + - module: community.crypto.x509_certificate +''' + +EXAMPLES = r''' +- name: Sign example file + community.crypto.openssl_signature: + privatekey_path: private.key + path: /tmp/example_file + register: sig + +- name: Verify signature of example file + community.crypto.openssl_signature_info: + certificate_path: cert.pem + path: /tmp/example_file + signature: "{{ sig.signature }}" + register: verify + +- name: Make sure the signature is valid + assert: + that: + - verify.valid +''' + +RETURN = r''' +valid: + description: C(true) means the signature was valid for the given file, C(false) means it was not. + returned: success + type: bool +''' + +import os +import traceback +import base64 + +from ansible_collections.community.crypto.plugins.module_utils.version import LooseVersion + +MINIMAL_CRYPTOGRAPHY_VERSION = '1.4' + +CRYPTOGRAPHY_IMP_ERR = None +try: + import cryptography + import cryptography.hazmat.primitives.asymmetric.padding + import cryptography.hazmat.primitives.hashes + CRYPTOGRAPHY_VERSION = LooseVersion(cryptography.__version__) +except ImportError: + CRYPTOGRAPHY_IMP_ERR = traceback.format_exc() + CRYPTOGRAPHY_FOUND = False +else: + CRYPTOGRAPHY_FOUND = True + +from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import ( + CRYPTOGRAPHY_HAS_DSA_SIGN, + CRYPTOGRAPHY_HAS_EC_SIGN, + CRYPTOGRAPHY_HAS_ED25519_SIGN, + CRYPTOGRAPHY_HAS_ED448_SIGN, + CRYPTOGRAPHY_HAS_RSA_SIGN, + OpenSSLObjectError, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.support import ( + OpenSSLObject, + load_certificate, +) + +from ansible.module_utils.common.text.converters import to_native +from ansible.module_utils.basic import AnsibleModule, missing_required_lib + + +class SignatureInfoBase(OpenSSLObject): + + def __init__(self, module, backend): + super(SignatureInfoBase, self).__init__( + path=module.params['path'], + state='present', + force=False, + check_mode=module.check_mode + ) + + self.backend = backend + + self.signature = module.params['signature'] + self.certificate_path = module.params['certificate_path'] + self.certificate_content = module.params['certificate_content'] + if self.certificate_content is not None: + self.certificate_content = self.certificate_content.encode('utf-8') + + def generate(self): + # Empty method because OpenSSLObject wants this + pass + + def dump(self): + # Empty method because OpenSSLObject wants this + pass + + +# Implementation with using cryptography +class SignatureInfoCryptography(SignatureInfoBase): + + def __init__(self, module, backend): + super(SignatureInfoCryptography, self).__init__(module, backend) + + def run(self): + _padding = cryptography.hazmat.primitives.asymmetric.padding.PKCS1v15() + _hash = cryptography.hazmat.primitives.hashes.SHA256() + + result = dict() + + try: + with open(self.path, "rb") as f: + _in = f.read() + + _signature = base64.b64decode(self.signature) + certificate = load_certificate( + path=self.certificate_path, + content=self.certificate_content, + backend=self.backend, + ) + public_key = certificate.public_key() + verified = False + valid = False + + if CRYPTOGRAPHY_HAS_DSA_SIGN: + try: + if isinstance(public_key, cryptography.hazmat.primitives.asymmetric.dsa.DSAPublicKey): + public_key.verify(_signature, _in, _hash) + verified = True + valid = True + except cryptography.exceptions.InvalidSignature: + verified = True + valid = False + + if CRYPTOGRAPHY_HAS_EC_SIGN: + try: + if isinstance(public_key, cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePublicKey): + public_key.verify(_signature, _in, cryptography.hazmat.primitives.asymmetric.ec.ECDSA(_hash)) + verified = True + valid = True + except cryptography.exceptions.InvalidSignature: + verified = True + valid = False + + if CRYPTOGRAPHY_HAS_ED25519_SIGN: + try: + if isinstance(public_key, cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PublicKey): + public_key.verify(_signature, _in) + verified = True + valid = True + except cryptography.exceptions.InvalidSignature: + verified = True + valid = False + + if CRYPTOGRAPHY_HAS_ED448_SIGN: + try: + if isinstance(public_key, cryptography.hazmat.primitives.asymmetric.ed448.Ed448PublicKey): + public_key.verify(_signature, _in) + verified = True + valid = True + except cryptography.exceptions.InvalidSignature: + verified = True + valid = False + + if CRYPTOGRAPHY_HAS_RSA_SIGN: + try: + if isinstance(public_key, cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey): + public_key.verify(_signature, _in, _padding, _hash) + verified = True + valid = True + except cryptography.exceptions.InvalidSignature: + verified = True + valid = False + + if not verified: + self.module.fail_json( + msg="Unsupported key type. Your cryptography version is {0}".format(CRYPTOGRAPHY_VERSION) + ) + result['valid'] = valid + return result + + except Exception as e: + raise OpenSSLObjectError(e) + + +def main(): + module = AnsibleModule( + argument_spec=dict( + certificate_path=dict(type='path'), + certificate_content=dict(type='str'), + path=dict(type='path', required=True), + signature=dict(type='str', required=True), + select_crypto_backend=dict(type='str', choices=['auto', 'cryptography'], default='auto'), + ), + mutually_exclusive=( + ['certificate_path', 'certificate_content'], + ), + required_one_of=( + ['certificate_path', 'certificate_content'], + ), + supports_check_mode=True, + ) + + if not os.path.isfile(module.params['path']): + module.fail_json( + name=module.params['path'], + msg='The file {0} does not exist'.format(module.params['path']) + ) + + backend = module.params['select_crypto_backend'] + if backend == 'auto': + # Detection what is possible + can_use_cryptography = CRYPTOGRAPHY_FOUND and CRYPTOGRAPHY_VERSION >= LooseVersion(MINIMAL_CRYPTOGRAPHY_VERSION) + + # Decision + if can_use_cryptography: + backend = 'cryptography' + + # Success? + if backend == 'auto': + module.fail_json(msg=("Cannot detect any of the required Python libraries " + "cryptography (>= {0})").format(MINIMAL_CRYPTOGRAPHY_VERSION)) + try: + if backend == 'cryptography': + if not CRYPTOGRAPHY_FOUND: + module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)), + exception=CRYPTOGRAPHY_IMP_ERR) + _sign = SignatureInfoCryptography(module, backend) + + result = _sign.run() + + module.exit_json(**result) + except OpenSSLObjectError as exc: + module.fail_json(msg=to_native(exc)) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/crypto/plugins/modules/x509_certificate.py b/ansible_collections/community/crypto/plugins/modules/x509_certificate.py new file mode 100644 index 00000000..398dfabc --- /dev/null +++ b/ansible_collections/community/crypto/plugins/modules/x509_certificate.py @@ -0,0 +1,419 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2016-2017, Yanis Guenane <yanis+ansible@guenane.org> +# Copyright (c) 2017, Markus Teufelberger <mteufelberger+ansible@mgit.at> +# 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 = r''' +--- +module: x509_certificate +short_description: Generate and/or check OpenSSL certificates +description: + - It implements a notion of provider (one of C(selfsigned), C(ownca), C(acme), and C(entrust)) + for your certificate. + - "Please note that the module regenerates existing certificate if it does not match the module's + options, or if it seems to be corrupt. If you are concerned that this could overwrite + your existing certificate, consider using the I(backup) option." + - Note that this module was called C(openssl_certificate) when included directly in Ansible up to version 2.9. + When moved to the collection C(community.crypto), it was renamed to + M(community.crypto.x509_certificate). From Ansible 2.10 on, it can still be used by the + old short name (or by C(ansible.builtin.openssl_certificate)), which redirects to + C(community.crypto.x509_certificate). When using FQCNs or when using the + L(collections,https://docs.ansible.com/ansible/latest/user_guide/collections_using.html#using-collections-in-a-playbook) + keyword, the new name M(community.crypto.x509_certificate) should be used to avoid + a deprecation warning. +author: + - Yanis Guenane (@Spredzy) + - Markus Teufelberger (@MarkusTeufelberger) +extends_documentation_fragment: + - ansible.builtin.files + - community.crypto.attributes + - community.crypto.attributes.files + - community.crypto.module_certificate + - community.crypto.module_certificate.backend_acme_documentation + - community.crypto.module_certificate.backend_entrust_documentation + - community.crypto.module_certificate.backend_ownca_documentation + - community.crypto.module_certificate.backend_selfsigned_documentation +attributes: + check_mode: + support: full + diff_mode: + support: full + safe_file_operations: + support: full +options: + state: + description: + - Whether the certificate should exist or not, taking action if the state is different from what is stated. + type: str + default: present + choices: [ absent, present ] + + path: + description: + - Remote absolute path where the generated certificate file should be created or is already located. + type: path + required: true + + provider: + description: + - Name of the provider to use to generate/retrieve the OpenSSL certificate. + Please see the examples on how to emulate it with + M(community.crypto.x509_certificate_info), M(community.crypto.openssl_csr_info), + M(community.crypto.openssl_privatekey_info) and M(ansible.builtin.assert). + - "The C(entrust) provider was added for Ansible 2.9 and requires credentials for the + L(Entrust Certificate Services,https://www.entrustdatacard.com/products/categories/ssl-certificates) (ECS) API." + - Required if I(state) is C(present). + type: str + choices: [ acme, entrust, ownca, selfsigned ] + + return_content: + description: + - If set to C(true), will return the (current or generated) certificate's content as I(certificate). + type: bool + default: false + version_added: '1.0.0' + + backup: + description: + - Create a backup file including a timestamp so you can get the original + certificate back if you overwrote it with a new one by accident. + type: bool + default: false + + csr_content: + version_added: '1.0.0' + privatekey_content: + version_added: '1.0.0' + acme_directory: + version_added: '1.0.0' + ownca_content: + version_added: '1.0.0' + ownca_privatekey_content: + version_added: '1.0.0' + +seealso: + - module: community.crypto.x509_certificate_pipe +''' + +EXAMPLES = r''' +- name: Generate a Self Signed OpenSSL certificate + community.crypto.x509_certificate: + path: /etc/ssl/crt/ansible.com.crt + privatekey_path: /etc/ssl/private/ansible.com.pem + csr_path: /etc/ssl/csr/ansible.com.csr + provider: selfsigned + +- name: Generate an OpenSSL certificate signed with your own CA certificate + community.crypto.x509_certificate: + path: /etc/ssl/crt/ansible.com.crt + csr_path: /etc/ssl/csr/ansible.com.csr + ownca_path: /etc/ssl/crt/ansible_CA.crt + ownca_privatekey_path: /etc/ssl/private/ansible_CA.pem + provider: ownca + +- name: Generate a Let's Encrypt Certificate + community.crypto.x509_certificate: + path: /etc/ssl/crt/ansible.com.crt + csr_path: /etc/ssl/csr/ansible.com.csr + provider: acme + acme_accountkey_path: /etc/ssl/private/ansible.com.pem + acme_challenge_path: /etc/ssl/challenges/ansible.com/ + +- name: Force (re-)generate a new Let's Encrypt Certificate + community.crypto.x509_certificate: + path: /etc/ssl/crt/ansible.com.crt + csr_path: /etc/ssl/csr/ansible.com.csr + provider: acme + acme_accountkey_path: /etc/ssl/private/ansible.com.pem + acme_challenge_path: /etc/ssl/challenges/ansible.com/ + force: true + +- name: Generate an Entrust certificate via the Entrust Certificate Services (ECS) API + community.crypto.x509_certificate: + path: /etc/ssl/crt/ansible.com.crt + csr_path: /etc/ssl/csr/ansible.com.csr + provider: entrust + entrust_requester_name: Jo Doe + entrust_requester_email: jdoe@ansible.com + entrust_requester_phone: 555-555-5555 + entrust_cert_type: STANDARD_SSL + entrust_api_user: apiusername + entrust_api_key: a^lv*32!cd9LnT + entrust_api_client_cert_path: /etc/ssl/entrust/ecs-client.crt + entrust_api_client_cert_key_path: /etc/ssl/entrust/ecs-key.crt + entrust_api_specification_path: /etc/ssl/entrust/api-docs/cms-api-2.1.0.yaml + +# The following example shows how to emulate the behavior of the removed +# "assertonly" provider with the x509_certificate_info, openssl_csr_info, +# openssl_privatekey_info and assert modules: + +- name: Get certificate information + community.crypto.x509_certificate_info: + path: /etc/ssl/crt/ansible.com.crt + # for valid_at, invalid_at and valid_in + valid_at: + one_day_ten_hours: "+1d10h" + fixed_timestamp: 20200331202428Z + ten_seconds: "+10" + register: result + +- name: Get CSR information + community.crypto.openssl_csr_info: + # Verifies that the CSR signature is valid; module will fail if not + path: /etc/ssl/csr/ansible.com.csr + register: result_csr + +- name: Get private key information + community.crypto.openssl_privatekey_info: + path: /etc/ssl/csr/ansible.com.key + register: result_privatekey + +- assert: + that: + # When private key was specified for assertonly, this was checked: + - result.public_key == result_privatekey.public_key + # When CSR was specified for assertonly, this was checked: + - result.public_key == result_csr.public_key + - result.subject_ordered == result_csr.subject_ordered + - result.extensions_by_oid == result_csr.extensions_by_oid + # signature_algorithms check + - "result.signature_algorithm == 'sha256WithRSAEncryption' or result.signature_algorithm == 'sha512WithRSAEncryption'" + # subject and subject_strict + - "result.subject.commonName == 'ansible.com'" + - "result.subject | length == 1" # the number must be the number of entries you check for + # issuer and issuer_strict + - "result.issuer.commonName == 'ansible.com'" + - "result.issuer | length == 1" # the number must be the number of entries you check for + # has_expired + - not result.expired + # version + - result.version == 3 + # key_usage and key_usage_strict + - "'Data Encipherment' in result.key_usage" + - "result.key_usage | length == 1" # the number must be the number of entries you check for + # extended_key_usage and extended_key_usage_strict + - "'DVCS' in result.extended_key_usage" + - "result.extended_key_usage | length == 1" # the number must be the number of entries you check for + # subject_alt_name and subject_alt_name_strict + - "'dns:ansible.com' in result.subject_alt_name" + - "result.subject_alt_name | length == 1" # the number must be the number of entries you check for + # not_before and not_after + - "result.not_before == '20190331202428Z'" + - "result.not_after == '20190413202428Z'" + # valid_at, invalid_at and valid_in + - "result.valid_at.one_day_ten_hours" # for valid_at + - "not result.valid_at.fixed_timestamp" # for invalid_at + - "result.valid_at.ten_seconds" # for valid_in +''' + +RETURN = r''' +filename: + description: Path to the generated certificate. + returned: changed or success + type: str + sample: /etc/ssl/crt/www.ansible.com.crt +backup_file: + description: Name of backup file created. + returned: changed and if I(backup) is C(true) + type: str + sample: /path/to/www.ansible.com.crt.2019-03-09@11:22~ +certificate: + description: The (current or generated) certificate's content. + returned: if I(state) is C(present) and I(return_content) is C(true) + type: str + version_added: '1.0.0' +''' + + +import os + +from ansible.module_utils.common.text.converters import to_native + +from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.certificate import ( + select_backend, + get_certificate_argument_spec, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.certificate_acme import ( + AcmeCertificateProvider, + add_acme_provider_to_argument_spec, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.certificate_entrust import ( + EntrustCertificateProvider, + add_entrust_provider_to_argument_spec, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.certificate_ownca import ( + OwnCACertificateProvider, + add_ownca_provider_to_argument_spec, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.certificate_selfsigned import ( + SelfSignedCertificateProvider, + add_selfsigned_provider_to_argument_spec, +) + +from ansible_collections.community.crypto.plugins.module_utils.io import ( + load_file_if_exists, + write_file, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import ( + OpenSSLObjectError, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.support import ( + OpenSSLObject, +) + + +class CertificateAbsent(OpenSSLObject): + def __init__(self, module): + super(CertificateAbsent, self).__init__( + module.params['path'], + module.params['state'], + module.params['force'], + module.check_mode + ) + self.module = module + self.return_content = module.params['return_content'] + self.backup = module.params['backup'] + self.backup_file = None + + def generate(self, module): + pass + + def remove(self, module): + if self.backup: + self.backup_file = module.backup_local(self.path) + super(CertificateAbsent, self).remove(module) + + def dump(self, check_mode=False): + result = { + 'changed': self.changed, + 'filename': self.path, + 'privatekey': self.module.params['privatekey_path'], + 'csr': self.module.params['csr_path'] + } + if self.backup_file: + result['backup_file'] = self.backup_file + if self.return_content: + result['certificate'] = None + + return result + + +class GenericCertificate(OpenSSLObject): + """Retrieve a certificate using the given module backend.""" + def __init__(self, module, module_backend): + super(GenericCertificate, self).__init__( + module.params['path'], + module.params['state'], + module.params['force'], + module.check_mode + ) + self.module = module + self.return_content = module.params['return_content'] + self.backup = module.params['backup'] + self.backup_file = None + + self.module_backend = module_backend + self.module_backend.set_existing(load_file_if_exists(self.path, module)) + + def generate(self, module): + if self.module_backend.needs_regeneration(): + if not self.check_mode: + self.module_backend.generate_certificate() + result = self.module_backend.get_certificate_data() + if self.backup: + self.backup_file = module.backup_local(self.path) + write_file(module, result) + self.changed = True + + file_args = module.load_file_common_arguments(module.params) + if module.check_file_absent_if_check_mode(file_args['path']): + self.changed = True + else: + self.changed = module.set_fs_attributes_if_different(file_args, self.changed) + + def check(self, module, perms_required=True): + """Ensure the resource is in its desired state.""" + return super(GenericCertificate, self).check(module, perms_required) and not self.module_backend.needs_regeneration() + + def dump(self, check_mode=False): + result = self.module_backend.dump(include_certificate=self.return_content) + result.update({ + 'changed': self.changed, + 'filename': self.path, + }) + if self.backup_file: + result['backup_file'] = self.backup_file + return result + + +def main(): + argument_spec = get_certificate_argument_spec() + add_acme_provider_to_argument_spec(argument_spec) + add_entrust_provider_to_argument_spec(argument_spec) + add_ownca_provider_to_argument_spec(argument_spec) + add_selfsigned_provider_to_argument_spec(argument_spec) + argument_spec.argument_spec.update(dict( + state=dict(type='str', default='present', choices=['present', 'absent']), + path=dict(type='path', required=True), + backup=dict(type='bool', default=False), + return_content=dict(type='bool', default=False), + )) + argument_spec.required_if.append(['state', 'present', ['provider']]) + module = argument_spec.create_ansible_module( + add_file_common_args=True, + supports_check_mode=True, + ) + + try: + if module.params['state'] == 'absent': + certificate = CertificateAbsent(module) + + if module.check_mode: + result = certificate.dump(check_mode=True) + result['changed'] = os.path.exists(module.params['path']) + module.exit_json(**result) + + certificate.remove(module) + + else: + base_dir = os.path.dirname(module.params['path']) or '.' + if not os.path.isdir(base_dir): + module.fail_json( + name=base_dir, + msg='The directory %s does not exist or the file is not a directory' % base_dir + ) + + provider = module.params['provider'] + provider_map = { + 'acme': AcmeCertificateProvider, + 'entrust': EntrustCertificateProvider, + 'ownca': OwnCACertificateProvider, + 'selfsigned': SelfSignedCertificateProvider, + } + + backend = module.params['select_crypto_backend'] + module_backend = select_backend(module, backend, provider_map[provider]()) + certificate = GenericCertificate(module, module_backend) + certificate.generate(module) + + result = certificate.dump() + module.exit_json(**result) + except OpenSSLObjectError as exc: + module.fail_json(msg=to_native(exc)) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/crypto/plugins/modules/x509_certificate_info.py b/ansible_collections/community/crypto/plugins/modules/x509_certificate_info.py new file mode 100644 index 00000000..4c7a2bc4 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/modules/x509_certificate_info.py @@ -0,0 +1,466 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2016-2017, Yanis Guenane <yanis+ansible@guenane.org> +# Copyright (c) 2017, Markus Teufelberger <mteufelberger+ansible@mgit.at> +# 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 = r''' +--- +module: x509_certificate_info +short_description: Provide information of OpenSSL X.509 certificates +description: + - This module allows one to query information on OpenSSL certificates. + - It uses the cryptography python library to interact with OpenSSL. + - Note that this module was called C(openssl_certificate_info) when included directly in Ansible + up to version 2.9. When moved to the collection C(community.crypto), it was renamed to + M(community.crypto.x509_certificate_info). From Ansible 2.10 on, it can still be used by the + old short name (or by C(ansible.builtin.openssl_certificate_info)), which redirects to + C(community.crypto.x509_certificate_info). When using FQCNs or when using the + L(collections,https://docs.ansible.com/ansible/latest/user_guide/collections_using.html#using-collections-in-a-playbook) + keyword, the new name M(community.crypto.x509_certificate_info) should be used to avoid + a deprecation warning. +requirements: + - cryptography >= 1.6 +author: + - Felix Fontein (@felixfontein) + - Yanis Guenane (@Spredzy) + - Markus Teufelberger (@MarkusTeufelberger) +extends_documentation_fragment: + - community.crypto.attributes + - community.crypto.attributes.info_module + - community.crypto.name_encoding +options: + path: + description: + - Remote absolute path where the certificate file is loaded from. + - Either I(path) or I(content) must be specified, but not both. + type: path + content: + description: + - Content of the X.509 certificate in PEM format. + - Either I(path) or I(content) must be specified, but not both. + type: str + version_added: '1.0.0' + valid_at: + description: + - A dict of names mapping to time specifications. Every time specified here + will be checked whether the certificate is valid at this point. See the + C(valid_at) return value for informations on the result. + - 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 C(+32w1d2h)), and ASN.1 TIME (in other words, pattern C(YYYYMMDDHHMMSSZ)). + Note that all timestamps will be treated as being in UTC. + type: dict + select_crypto_backend: + description: + - Determines which crypto backend to use. + - The default choice is C(auto), which tries to use C(cryptography) if available. + - If set to C(cryptography), will try to use the L(cryptography,https://cryptography.io/) library. + type: str + default: auto + choices: [ auto, cryptography ] + +notes: + - All timestamp values are provided in ASN.1 TIME format, in other words, following the C(YYYYMMDDHHMMSSZ) pattern. + They are all in UTC. +seealso: + - module: community.crypto.x509_certificate + - module: community.crypto.x509_certificate_pipe + - ref: community.crypto.x509_certificate_info filter <ansible_collections.community.crypto.x509_certificate_info_filter> + # - plugin: community.crypto.x509_certificate_info + # plugin_type: filter + description: A filter variant of this module. +''' + +EXAMPLES = r''' +- name: Generate a Self Signed OpenSSL certificate + community.crypto.x509_certificate: + path: /etc/ssl/crt/ansible.com.crt + privatekey_path: /etc/ssl/private/ansible.com.pem + csr_path: /etc/ssl/csr/ansible.com.csr + provider: selfsigned + + +# Get information on the certificate + +- name: Get information on generated certificate + community.crypto.x509_certificate_info: + path: /etc/ssl/crt/ansible.com.crt + register: result + +- name: Dump information + ansible.builtin.debug: + var: result + + +# Check whether the certificate is valid or not valid at certain times, fail +# if this is not the case. The first task (x509_certificate_info) collects +# the information, and the second task (assert) validates the result and +# makes the playbook fail in case something is not as expected. + +- name: Test whether that certificate is valid tomorrow and/or in three weeks + community.crypto.x509_certificate_info: + path: /etc/ssl/crt/ansible.com.crt + valid_at: + point_1: "+1d" + point_2: "+3w" + register: result + +- name: Validate that certificate is valid tomorrow, but not in three weeks + assert: + that: + - result.valid_at.point_1 # valid in one day + - not result.valid_at.point_2 # not valid in three weeks +''' + +RETURN = r''' +expired: + description: Whether the certificate is expired (in other words, C(notAfter) is in the past). + returned: success + type: bool +basic_constraints: + description: Entries in the C(basic_constraints) extension, or C(none) if extension is not present. + returned: success + type: list + elements: str + sample: ["CA:TRUE", "pathlen:1"] +basic_constraints_critical: + description: Whether the C(basic_constraints) extension is critical. + returned: success + type: bool +extended_key_usage: + description: Entries in the C(extended_key_usage) extension, or C(none) if extension is not present. + returned: success + type: list + elements: str + sample: [Biometric Info, DVCS, Time Stamping] +extended_key_usage_critical: + description: Whether the C(extended_key_usage) extension is critical. + returned: success + type: bool +extensions_by_oid: + description: Returns a dictionary for every extension OID. + returned: success + type: dict + contains: + critical: + description: Whether the extension is critical. + returned: success + type: bool + value: + description: + - The Base64 encoded value (in DER format) of the extension. + - B(Note) that depending on the C(cryptography) version used, it is + not possible to extract the ASN.1 content of the extension, but only + to provide the re-encoded content of the extension in case it was + parsed by C(cryptography). This should usually result in exactly the + same value, except if the original extension value was malformed. + returned: success + type: str + sample: "MAMCAQU=" + sample: {"1.3.6.1.5.5.7.1.24": { "critical": false, "value": "MAMCAQU="}} +key_usage: + description: Entries in the C(key_usage) extension, or C(none) if extension is not present. + returned: success + type: str + sample: [Key Agreement, Data Encipherment] +key_usage_critical: + description: Whether the C(key_usage) extension is critical. + returned: success + type: bool +subject_alt_name: + description: + - Entries in the C(subject_alt_name) extension, or C(none) if extension is not present. + - See I(name_encoding) for how IDNs are handled. + returned: success + type: list + elements: str + sample: ["DNS:www.ansible.com", "IP:1.2.3.4"] +subject_alt_name_critical: + description: Whether the C(subject_alt_name) extension is critical. + returned: success + type: bool +ocsp_must_staple: + description: C(true) if the OCSP Must Staple extension is present, C(none) otherwise. + returned: success + type: bool +ocsp_must_staple_critical: + description: Whether the C(ocsp_must_staple) extension is critical. + returned: success + type: bool +issuer: + description: + - The certificate's issuer. + - Note that for repeated values, only the last one will be returned. + returned: success + type: dict + sample: {"organizationName": "Ansible", "commonName": "ca.example.com"} +issuer_ordered: + description: The certificate's issuer as an ordered list of tuples. + returned: success + type: list + elements: list + sample: [["organizationName", "Ansible"], ["commonName": "ca.example.com"]] +subject: + description: + - The certificate's subject as a dictionary. + - Note that for repeated values, only the last one will be returned. + returned: success + type: dict + sample: {"commonName": "www.example.com", "emailAddress": "test@example.com"} +subject_ordered: + description: The certificate's subject as an ordered list of tuples. + returned: success + type: list + elements: list + sample: [["commonName", "www.example.com"], ["emailAddress": "test@example.com"]] +not_after: + description: C(notAfter) date as ASN.1 TIME. + returned: success + type: str + sample: '20190413202428Z' +not_before: + description: C(notBefore) date as ASN.1 TIME. + returned: success + type: str + sample: '20190331202428Z' +public_key: + description: Certificate's public key in PEM format. + returned: success + type: str + sample: "-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8A..." +public_key_type: + description: + - The certificate's public key's type. + - One of C(RSA), C(DSA), C(ECC), C(Ed25519), C(X25519), C(Ed448), or C(X448). + - Will start with C(unknown) if the key type cannot be determined. + returned: success + type: str + version_added: 1.7.0 + sample: RSA +public_key_data: + description: + - Public key data. Depends on the public key's type. + returned: success + type: dict + version_added: 1.7.0 + contains: + size: + description: + - Bit size of modulus (RSA) or prime number (DSA). + type: int + returned: When C(public_key_type=RSA) or C(public_key_type=DSA) + modulus: + description: + - The RSA key's modulus. + type: int + returned: When C(public_key_type=RSA) + exponent: + description: + - The RSA key's public exponent. + type: int + returned: When C(public_key_type=RSA) + p: + description: + - The C(p) value for DSA. + - This is the prime modulus upon which arithmetic takes place. + type: int + returned: When C(public_key_type=DSA) + q: + description: + - The C(q) value for DSA. + - This is a prime that divides C(p - 1), and at the same time the order of the subgroup of the + multiplicative group of the prime field used. + type: int + returned: When C(public_key_type=DSA) + g: + description: + - The C(g) value for DSA. + - This is the element spanning the subgroup of the multiplicative group of the prime field used. + type: int + returned: When C(public_key_type=DSA) + curve: + description: + - The curve's name for ECC. + type: str + returned: When C(public_key_type=ECC) + exponent_size: + description: + - The maximum number of bits of a private key. This is basically the bit size of the subgroup used. + type: int + returned: When C(public_key_type=ECC) + x: + description: + - The C(x) coordinate for the public point on the elliptic curve. + type: int + returned: When C(public_key_type=ECC) + y: + description: + - For C(public_key_type=ECC), this is the C(y) coordinate for the public point on the elliptic curve. + - For C(public_key_type=DSA), this is the publicly known group element whose discrete logarithm w.r.t. C(g) is the private key. + type: int + returned: When C(public_key_type=DSA) or C(public_key_type=ECC) +public_key_fingerprints: + description: + - Fingerprints of certificate's public key. + - For every hash algorithm available, the fingerprint is computed. + returned: success + type: dict + sample: "{'sha256': 'd4:b3:aa:6d:c8:04:ce:4e:ba:f6:29:4d:92:a3:94:b0:c2:ff:bd:bf:33:63:11:43:34:0f:51:b0:95:09:2f:63', + 'sha512': 'f7:07:4a:f0:b0:f0:e6:8b:95:5f:f9:e6:61:0a:32:68:f1..." +fingerprints: + description: + - Fingerprints of the DER-encoded form of the whole certificate. + - For every hash algorithm available, the fingerprint is computed. + returned: success + type: dict + sample: "{'sha256': 'd4:b3:aa:6d:c8:04:ce:4e:ba:f6:29:4d:92:a3:94:b0:c2:ff:bd:bf:33:63:11:43:34:0f:51:b0:95:09:2f:63', + 'sha512': 'f7:07:4a:f0:b0:f0:e6:8b:95:5f:f9:e6:61:0a:32:68:f1..." + version_added: 1.2.0 +signature_algorithm: + description: The signature algorithm used to sign the certificate. + returned: success + type: str + sample: sha256WithRSAEncryption +serial_number: + description: The certificate's serial number. + returned: success + type: int + sample: 1234 +version: + description: The certificate version. + returned: success + type: int + sample: 3 +valid_at: + description: For every time stamp provided in the I(valid_at) option, a + boolean whether the certificate is valid at that point in time + or not. + returned: success + type: dict +subject_key_identifier: + description: + - The certificate's subject key identifier. + - The identifier is returned in hexadecimal, with C(:) used to separate bytes. + - Is C(none) if the C(SubjectKeyIdentifier) extension is not present. + returned: success + type: str + sample: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff:00:11:22:33' +authority_key_identifier: + description: + - The certificate's authority key identifier. + - The identifier is returned in hexadecimal, with C(:) used to separate bytes. + - Is C(none) if the C(AuthorityKeyIdentifier) extension is not present. + returned: success + type: str + sample: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff:00:11:22:33' +authority_cert_issuer: + description: + - The certificate's authority cert issuer as a list of general names. + - Is C(none) if the C(AuthorityKeyIdentifier) extension is not present. + - See I(name_encoding) for how IDNs are handled. + returned: success + type: list + elements: str + sample: ["DNS:www.ansible.com", "IP:1.2.3.4"] +authority_cert_serial_number: + description: + - The certificate's authority cert serial number. + - Is C(none) if the C(AuthorityKeyIdentifier) extension is not present. + returned: success + type: int + sample: 12345 +ocsp_uri: + description: The OCSP responder URI, if included in the certificate. Will be + C(none) if no OCSP responder URI is included. + returned: success + type: str +issuer_uri: + description: The Issuer URI, if included in the certificate. Will be + C(none) if no issuer URI is included. + returned: success + type: str + version_added: 2.9.0 +''' + + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.six import string_types +from ansible.module_utils.common.text.converters import to_native + +from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import ( + 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.module_backends.certificate_info import ( + select_backend, +) + + +def main(): + module = AnsibleModule( + argument_spec=dict( + path=dict(type='path'), + content=dict(type='str'), + valid_at=dict(type='dict'), + name_encoding=dict(type='str', default='ignore', choices=['ignore', 'idna', 'unicode']), + select_crypto_backend=dict(type='str', default='auto', choices=['auto', 'cryptography']), + ), + required_one_of=( + ['path', 'content'], + ), + mutually_exclusive=( + ['path', 'content'], + ), + supports_check_mode=True, + ) + + if module.params['content'] is not None: + data = module.params['content'].encode('utf-8') + else: + try: + with open(module.params['path'], 'rb') as f: + data = f.read() + except (IOError, OSError) as e: + module.fail_json(msg='Error while reading certificate file from disk: {0}'.format(e)) + + backend, module_backend = select_backend(module, module.params['select_crypto_backend'], data) + + valid_at = module.params['valid_at'] + if valid_at: + for k, v in valid_at.items(): + if not isinstance(v, string_types): + module.fail_json( + msg='The value for valid_at.{0} must be of type string (got {1})'.format(k, type(v)) + ) + valid_at[k] = get_relative_time_option(v, 'valid_at.{0}'.format(k)) + + try: + result = module_backend.get_info() + + not_before = module_backend.get_not_before() + not_after = module_backend.get_not_after() + + result['valid_at'] = dict() + if valid_at: + for k, v in valid_at.items(): + result['valid_at'][k] = not_before <= v <= not_after + + module.exit_json(**result) + except OpenSSLObjectError as exc: + module.fail_json(msg=to_native(exc)) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/crypto/plugins/modules/x509_certificate_pipe.py b/ansible_collections/community/crypto/plugins/modules/x509_certificate_pipe.py new file mode 100644 index 00000000..440a2cdf --- /dev/null +++ b/ansible_collections/community/crypto/plugins/modules/x509_certificate_pipe.py @@ -0,0 +1,211 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2016-2017, Yanis Guenane <yanis+ansible@guenane.org> +# Copyright (c) 2017, Markus Teufelberger <mteufelberger+ansible@mgit.at> +# Copyright (2) 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 + + +DOCUMENTATION = r''' +--- +module: x509_certificate_pipe +short_description: Generate and/or check OpenSSL certificates +version_added: 1.3.0 +description: + - It implements a notion of provider (ie. C(selfsigned), C(ownca), C(entrust)) + for your certificate. + - "Please note that the module regenerates an existing certificate if it does not match the module's + options, or if it seems to be corrupt. If you are concerned that this could overwrite + your existing certificate, consider using the I(backup) option." +author: + - Yanis Guenane (@Spredzy) + - Markus Teufelberger (@MarkusTeufelberger) + - Felix Fontein (@felixfontein) +extends_documentation_fragment: + - community.crypto.attributes + - community.crypto.module_certificate + - community.crypto.module_certificate.backend_entrust_documentation + - community.crypto.module_certificate.backend_ownca_documentation + - community.crypto.module_certificate.backend_selfsigned_documentation +attributes: + check_mode: + support: full + diff_mode: + support: full +options: + provider: + description: + - Name of the provider to use to generate/retrieve the OpenSSL certificate. + - "The C(entrust) provider requires credentials for the + L(Entrust Certificate Services,https://www.entrustdatacard.com/products/categories/ssl-certificates) (ECS) API." + type: str + choices: [ entrust, ownca, selfsigned ] + required: true + + content: + description: + - The existing certificate. + type: str + +seealso: + - module: community.crypto.x509_certificate +''' + +EXAMPLES = r''' +- name: Generate a Self Signed OpenSSL certificate + community.crypto.x509_certificate_pipe: + provider: selfsigned + privatekey_path: /etc/ssl/private/ansible.com.pem + csr_path: /etc/ssl/csr/ansible.com.csr + register: result +- name: Print the certificate + ansible.builtin.debug: + var: result.certificate + +# In the following example, both CSR and certificate file are stored on the +# machine where ansible-playbook is executed, while the OwnCA data (certificate, +# private key) are stored on the remote machine. + +- name: (1/2) Generate an OpenSSL Certificate with the CSR provided inline + community.crypto.x509_certificate_pipe: + provider: ownca + content: "{{ lookup('file', '/etc/ssl/csr/www.ansible.com.crt') }}" + csr_content: "{{ lookup('file', '/etc/ssl/csr/www.ansible.com.csr') }}" + ownca_cert: /path/to/ca_cert.crt + ownca_privatekey: /path/to/ca_cert.key + ownca_privatekey_passphrase: hunter2 + register: result + +- name: (2/2) Store certificate + ansible.builtin.copy: + dest: /etc/ssl/csr/www.ansible.com.crt + content: "{{ result.certificate }}" + delegate_to: localhost + when: result is changed + +# In the following example, the certificate from another machine is signed by +# our OwnCA whose private key and certificate are only available on this +# machine (where ansible-playbook is executed), without having to write +# the certificate file to disk on localhost. The CSR could have been +# provided by community.crypto.openssl_csr_pipe earlier, or also have been +# read from the remote machine. + +- name: (1/3) Read certificate's contents from remote machine + ansible.builtin.slurp: + src: /etc/ssl/csr/www.ansible.com.crt + register: certificate_content + +- name: (2/3) Generate an OpenSSL Certificate with the CSR provided inline + community.crypto.x509_certificate_pipe: + provider: ownca + content: "{{ certificate_content.content | b64decode }}" + csr_content: "{{ the_csr }}" + ownca_cert: /path/to/ca_cert.crt + ownca_privatekey: /path/to/ca_cert.key + ownca_privatekey_passphrase: hunter2 + delegate_to: localhost + register: result + +- name: (3/3) Store certificate + ansible.builtin.copy: + dest: /etc/ssl/csr/www.ansible.com.crt + content: "{{ result.certificate }}" + when: result is changed +''' + +RETURN = r''' +certificate: + description: The (current or generated) certificate's content. + returned: changed or success + type: str +''' + + +from ansible.module_utils.common.text.converters import to_native + +from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.certificate import ( + select_backend, + get_certificate_argument_spec, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.certificate_entrust import ( + EntrustCertificateProvider, + add_entrust_provider_to_argument_spec, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.certificate_ownca import ( + OwnCACertificateProvider, + add_ownca_provider_to_argument_spec, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.certificate_selfsigned import ( + SelfSignedCertificateProvider, + add_selfsigned_provider_to_argument_spec, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import ( + OpenSSLObjectError, +) + + +class GenericCertificate(object): + """Retrieve a certificate using the given module backend.""" + def __init__(self, module, module_backend): + self.check_mode = module.check_mode + self.module_backend = module_backend + self.changed = False + if module.params['content'] is not None: + self.module_backend.set_existing(module.params['content'].encode('utf-8')) + + def generate(self, module): + if self.module_backend.needs_regeneration(): + if not self.check_mode: + self.module_backend.generate_certificate() + self.changed = True + + def dump(self, check_mode=False): + result = self.module_backend.dump(include_certificate=True) + result.update({ + 'changed': self.changed, + }) + return result + + +def main(): + argument_spec = get_certificate_argument_spec() + argument_spec.argument_spec['provider']['required'] = True + add_entrust_provider_to_argument_spec(argument_spec) + add_ownca_provider_to_argument_spec(argument_spec) + add_selfsigned_provider_to_argument_spec(argument_spec) + argument_spec.argument_spec.update(dict( + content=dict(type='str'), + )) + module = argument_spec.create_ansible_module( + supports_check_mode=True, + ) + + try: + provider = module.params['provider'] + provider_map = { + 'entrust': EntrustCertificateProvider, + 'ownca': OwnCACertificateProvider, + 'selfsigned': SelfSignedCertificateProvider, + } + + backend = module.params['select_crypto_backend'] + module_backend = select_backend(module, backend, provider_map[provider]()) + certificate = GenericCertificate(module, module_backend) + certificate.generate(module) + result = certificate.dump() + module.exit_json(**result) + except OpenSSLObjectError as exc: + module.fail_json(msg=to_native(exc)) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/crypto/plugins/modules/x509_crl.py b/ansible_collections/community/crypto/plugins/modules/x509_crl.py new file mode 100644 index 00000000..cb0ea24f --- /dev/null +++ b/ansible_collections/community/crypto/plugins/modules/x509_crl.py @@ -0,0 +1,914 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2019, 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 = r''' +--- +module: x509_crl +version_added: '1.0.0' +short_description: Generate Certificate Revocation Lists (CRLs) +description: + - This module allows one to (re)generate or update Certificate Revocation Lists (CRLs). + - Certificates on the revocation list can be either specified by serial number and (optionally) their issuer, + or as a path to a certificate file in PEM format. +requirements: + - cryptography >= 1.2 +author: + - Felix Fontein (@felixfontein) +extends_documentation_fragment: + - ansible.builtin.files + - community.crypto.attributes + - community.crypto.attributes.files + - community.crypto.name_encoding +attributes: + check_mode: + support: full + diff_mode: + support: full + safe_file_operations: + support: full +options: + state: + description: + - Whether the CRL file should exist or not, taking action if the state is different from what is stated. + type: str + default: present + choices: [ absent, present ] + + mode: + description: + - Defines how to process entries of existing CRLs. + - If set to C(generate), makes sure that the CRL has the exact set of revoked certificates + as specified in I(revoked_certificates). + - If set to C(update), makes sure that the CRL contains the revoked certificates from + I(revoked_certificates), but can also contain other revoked certificates. If the CRL file + already exists, all entries from the existing CRL will also be included in the new CRL. + When using C(update), you might be interested in setting I(ignore_timestamps) to C(true). + type: str + default: generate + choices: [ generate, update ] + + force: + description: + - Should the CRL be forced to be regenerated. + type: bool + default: false + + backup: + description: + - Create a backup file including a timestamp so you can get the original + CRL back if you overwrote it with a new one by accident. + type: bool + default: false + + path: + description: + - Remote absolute path where the generated CRL file should be created or is already located. + type: path + required: true + + format: + description: + - Whether the CRL file should be in PEM or DER format. + - If an existing CRL file does match everything but I(format), it will be converted to the correct format + instead of regenerated. + type: str + choices: [pem, der] + default: pem + + privatekey_path: + description: + - Path to the CA's private key to use when signing the CRL. + - Either I(privatekey_path) or I(privatekey_content) must be specified if I(state) is C(present), but not both. + type: path + + privatekey_content: + description: + - The content of the CA's private key to use when signing the CRL. + - Either I(privatekey_path) or I(privatekey_content) must be specified if I(state) is C(present), but not both. + type: str + + privatekey_passphrase: + description: + - The passphrase for the I(privatekey_path). + - This is required if the private key is password protected. + type: str + + issuer: + description: + - Key/value pairs that will be present in the issuer name field of the CRL. + - If you need to specify more than one value with the same key, use a list as value. + - If the order of the components is important, use I(issuer_ordered). + - One of I(issuer) and I(issuer_ordered) is required if I(state) is C(present). + - Mutually exclusive with I(issuer_ordered). + type: dict + issuer_ordered: + description: + - A list of dictionaries, where every dictionary must contain one key/value pair. + This key/value pair will be present in the issuer name field of the CRL. + - If you want to specify more than one value with the same key in a row, you can + use a list as value. + - One of I(issuer) and I(issuer_ordered) is required if I(state) is C(present). + - Mutually exclusive with I(issuer). + type: list + elements: dict + version_added: 2.0.0 + + last_update: + description: + - The point in time from which this CRL can be trusted. + - 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 C(+32w1d2h)). + - Note that if using relative time this module is NOT idempotent, except when + I(ignore_timestamps) is set to C(true). + type: str + default: "+0s" + + next_update: + description: + - "The absolute latest point in time by which this I(issuer) is expected to have issued + another CRL. Many clients will treat a CRL as expired once I(next_update) occurs." + - 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 C(+32w1d2h)). + - Note that if using relative time this module is NOT idempotent, except when + I(ignore_timestamps) is set to C(true). + - Required if I(state) is C(present). + type: str + + digest: + description: + - Digest algorithm to be used when signing the CRL. + type: str + default: sha256 + + revoked_certificates: + description: + - List of certificates to be revoked. + - Required if I(state) is C(present). + type: list + elements: dict + suboptions: + path: + description: + - Path to a certificate in PEM format. + - The serial number and issuer will be extracted from the certificate. + - Mutually exclusive with I(content) and I(serial_number). One of these three options + must be specified. + type: path + content: + description: + - Content of a certificate in PEM format. + - The serial number and issuer will be extracted from the certificate. + - Mutually exclusive with I(path) and I(serial_number). One of these three options + must be specified. + type: str + serial_number: + description: + - Serial number of the certificate. + - Mutually exclusive with I(path) and I(content). One of these three options must + be specified. + type: int + revocation_date: + description: + - The point in time the certificate was revoked. + - 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 C(+32w1d2h)). + - Note that if using relative time this module is NOT idempotent, except when + I(ignore_timestamps) is set to C(true). + type: str + default: "+0s" + issuer: + description: + - The certificate's issuer. + - "Example: C(DNS:ca.example.org)" + type: list + elements: str + issuer_critical: + description: + - Whether the certificate issuer extension should be critical. + type: bool + default: false + reason: + description: + - The value for the revocation reason extension. + type: str + choices: + - unspecified + - key_compromise + - ca_compromise + - affiliation_changed + - superseded + - cessation_of_operation + - certificate_hold + - privilege_withdrawn + - aa_compromise + - remove_from_crl + reason_critical: + description: + - Whether the revocation reason extension should be critical. + type: bool + default: false + invalidity_date: + description: + - The point in time it was known/suspected that the private key was compromised + or that the certificate otherwise became invalid. + - 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 C(+32w1d2h)). + - Note that if using relative time this module is NOT idempotent. This will NOT + change when I(ignore_timestamps) is set to C(true). + type: str + invalidity_date_critical: + description: + - Whether the invalidity date extension should be critical. + type: bool + default: false + + ignore_timestamps: + description: + - Whether the timestamps I(last_update), I(next_update) and I(revocation_date) (in + I(revoked_certificates)) should be ignored for idempotency checks. The timestamp + I(invalidity_date) in I(revoked_certificates) will never be ignored. + - Use this in combination with relative timestamps for these values to get idempotency. + type: bool + default: false + + return_content: + description: + - If set to C(true), will return the (current or generated) CRL's content as I(crl). + type: bool + default: false + +notes: + - All ASN.1 TIME values should be specified following the YYYYMMDDHHMMSSZ pattern. + - Date specified should be UTC. Minutes and seconds are mandatory. +''' + +EXAMPLES = r''' +- name: Generate a CRL + community.crypto.x509_crl: + path: /etc/ssl/my-ca.crl + privatekey_path: /etc/ssl/private/my-ca.pem + issuer: + CN: My CA + last_update: "+0s" + next_update: "+7d" + revoked_certificates: + - serial_number: 1234 + revocation_date: 20190331202428Z + issuer: + CN: My CA + - serial_number: 2345 + revocation_date: 20191013152910Z + reason: affiliation_changed + invalidity_date: 20191001000000Z + - path: /etc/ssl/crt/revoked-cert.pem + revocation_date: 20191010010203Z +''' + +RETURN = r''' +filename: + description: Path to the generated CRL. + returned: changed or success + type: str + sample: /path/to/my-ca.crl +backup_file: + description: Name of backup file created. + returned: changed and if I(backup) is C(true) + type: str + sample: /path/to/my-ca.crl.2019-03-09@11:22~ +privatekey: + description: Path to the private CA key. + returned: changed or success + type: str + sample: /path/to/my-ca.pem +format: + description: + - Whether the CRL is in PEM format (C(pem)) or in DER format (C(der)). + returned: success + type: str + sample: pem +issuer: + description: + - The CRL's issuer. + - Note that for repeated values, only the last one will be returned. + - See I(name_encoding) for how IDNs are handled. + returned: success + type: dict + sample: {"organizationName": "Ansible", "commonName": "ca.example.com"} +issuer_ordered: + description: The CRL's issuer as an ordered list of tuples. + returned: success + type: list + elements: list + sample: [["organizationName", "Ansible"], ["commonName": "ca.example.com"]] +last_update: + description: The point in time from which this CRL can be trusted as ASN.1 TIME. + returned: success + type: str + sample: 20190413202428Z +next_update: + description: The point in time from which a new CRL will be issued and the client has to check for it as ASN.1 TIME. + returned: success + type: str + sample: 20190413202428Z +digest: + description: The signature algorithm used to sign the CRL. + returned: success + type: str + sample: sha256WithRSAEncryption +revoked_certificates: + description: List of certificates to be revoked. + returned: success + type: list + elements: dict + contains: + serial_number: + description: Serial number of the certificate. + type: int + sample: 1234 + revocation_date: + description: The point in time the certificate was revoked as ASN.1 TIME. + type: str + sample: 20190413202428Z + issuer: + description: + - The certificate's issuer. + - See I(name_encoding) for how IDNs are handled. + type: list + elements: str + sample: ["DNS:ca.example.org"] + issuer_critical: + description: Whether the certificate issuer extension is critical. + type: bool + sample: false + reason: + description: + - The value for the revocation reason extension. + - One of C(unspecified), C(key_compromise), C(ca_compromise), C(affiliation_changed), C(superseded), + C(cessation_of_operation), C(certificate_hold), C(privilege_withdrawn), C(aa_compromise), and + C(remove_from_crl). + type: str + sample: key_compromise + reason_critical: + description: Whether the revocation reason extension is critical. + type: bool + sample: false + invalidity_date: + description: | + The point in time it was known/suspected that the private key was compromised + or that the certificate otherwise became invalid as ASN.1 TIME. + type: str + sample: 20190413202428Z + invalidity_date_critical: + description: Whether the invalidity date extension is critical. + type: bool + sample: false +crl: + description: + - The (current or generated) CRL's content. + - Will be the CRL itself if I(format) is C(pem), and Base64 of the + CRL if I(format) is C(der). + returned: if I(state) is C(present) and I(return_content) is C(true) + type: str +''' + + +import base64 +import os +import traceback + +from ansible.module_utils.basic import AnsibleModule, missing_required_lib +from ansible.module_utils.common.text.converters import to_native, to_text + +from ansible_collections.community.crypto.plugins.module_utils.version import LooseVersion + +from ansible_collections.community.crypto.plugins.module_utils.io import ( + write_file, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import ( + OpenSSLObjectError, + OpenSSLBadPassphraseError, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.support import ( + OpenSSLObject, + load_privatekey, + load_certificate, + parse_name_field, + parse_ordered_name_field, + get_relative_time_option, + select_message_digest, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_support import ( + cryptography_decode_name, + cryptography_get_name, + cryptography_key_needs_digest_for_signing, + cryptography_name_to_oid, + cryptography_oid_to_name, + cryptography_serial_number_of_cert, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_crl import ( + REVOCATION_REASON_MAP, + TIMESTAMP_FORMAT, + cryptography_decode_revoked_certificate, + cryptography_dump_revoked, + cryptography_get_signature_algorithm_oid_from_crl, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.pem import ( + identify_pem_format, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.crl_info import ( + get_crl_info, +) + +MINIMAL_CRYPTOGRAPHY_VERSION = '1.2' + +CRYPTOGRAPHY_IMP_ERR = None +try: + import cryptography + from cryptography import x509 + from cryptography.hazmat.backends import default_backend + from cryptography.hazmat.primitives.serialization import Encoding + from cryptography.x509 import ( + CertificateRevocationListBuilder, + RevokedCertificateBuilder, + NameAttribute, + Name, + ) + CRYPTOGRAPHY_VERSION = LooseVersion(cryptography.__version__) +except ImportError: + CRYPTOGRAPHY_IMP_ERR = traceback.format_exc() + CRYPTOGRAPHY_FOUND = False +else: + CRYPTOGRAPHY_FOUND = True + + +class CRLError(OpenSSLObjectError): + pass + + +class CRL(OpenSSLObject): + + def __init__(self, module): + super(CRL, self).__init__( + module.params['path'], + module.params['state'], + module.params['force'], + module.check_mode + ) + + self.format = module.params['format'] + + self.update = module.params['mode'] == 'update' + self.ignore_timestamps = module.params['ignore_timestamps'] + self.return_content = module.params['return_content'] + self.name_encoding = module.params['name_encoding'] + self.crl_content = None + + self.privatekey_path = module.params['privatekey_path'] + self.privatekey_content = module.params['privatekey_content'] + if self.privatekey_content is not None: + self.privatekey_content = self.privatekey_content.encode('utf-8') + self.privatekey_passphrase = module.params['privatekey_passphrase'] + + try: + if module.params['issuer_ordered']: + self.issuer_ordered = True + self.issuer = parse_ordered_name_field(module.params['issuer_ordered'], 'issuer_ordered') + else: + self.issuer_ordered = False + self.issuer = parse_name_field(module.params['issuer'], 'issuer') + except (TypeError, ValueError) as exc: + module.fail_json(msg=to_native(exc)) + + self.last_update = get_relative_time_option(module.params['last_update'], 'last_update') + self.next_update = get_relative_time_option(module.params['next_update'], 'next_update') + + self.digest = select_message_digest(module.params['digest']) + if self.digest is None: + raise CRLError('The digest "{0}" is not supported'.format(module.params['digest'])) + + self.revoked_certificates = [] + for i, rc in enumerate(module.params['revoked_certificates']): + result = { + 'serial_number': None, + 'revocation_date': None, + 'issuer': None, + 'issuer_critical': False, + 'reason': None, + 'reason_critical': False, + 'invalidity_date': None, + 'invalidity_date_critical': False, + } + path_prefix = 'revoked_certificates[{0}].'.format(i) + if rc['path'] is not None or rc['content'] is not None: + # Load certificate from file or content + try: + if rc['content'] is not None: + rc['content'] = rc['content'].encode('utf-8') + cert = load_certificate(rc['path'], content=rc['content'], backend='cryptography') + result['serial_number'] = cryptography_serial_number_of_cert(cert) + except OpenSSLObjectError as e: + if rc['content'] is not None: + module.fail_json( + msg='Cannot parse certificate from {0}content: {1}'.format(path_prefix, to_native(e)) + ) + else: + module.fail_json( + msg='Cannot read certificate "{1}" from {0}path: {2}'.format(path_prefix, rc['path'], to_native(e)) + ) + else: + # Specify serial_number (and potentially issuer) directly + result['serial_number'] = rc['serial_number'] + # All other options + if rc['issuer']: + result['issuer'] = [cryptography_get_name(issuer, 'issuer') for issuer in rc['issuer']] + result['issuer_critical'] = rc['issuer_critical'] + result['revocation_date'] = get_relative_time_option( + rc['revocation_date'], + path_prefix + 'revocation_date' + ) + if rc['reason']: + result['reason'] = REVOCATION_REASON_MAP[rc['reason']] + result['reason_critical'] = rc['reason_critical'] + if rc['invalidity_date']: + result['invalidity_date'] = get_relative_time_option( + rc['invalidity_date'], + path_prefix + 'invalidity_date' + ) + result['invalidity_date_critical'] = rc['invalidity_date_critical'] + self.revoked_certificates.append(result) + + self.module = module + + self.backup = module.params['backup'] + self.backup_file = None + + try: + self.privatekey = load_privatekey( + path=self.privatekey_path, + content=self.privatekey_content, + passphrase=self.privatekey_passphrase, + backend='cryptography' + ) + except OpenSSLBadPassphraseError as exc: + raise CRLError(exc) + + self.crl = None + try: + with open(self.path, 'rb') as f: + data = f.read() + self.actual_format = 'pem' if identify_pem_format(data) else 'der' + if self.actual_format == 'pem': + self.crl = x509.load_pem_x509_crl(data, default_backend()) + if self.return_content: + self.crl_content = data + else: + self.crl = x509.load_der_x509_crl(data, default_backend()) + if self.return_content: + self.crl_content = base64.b64encode(data) + except Exception as dummy: + self.crl_content = None + self.actual_format = self.format + data = None + + self.diff_after = self.diff_before = self._get_info(data) + + def _get_info(self, data): + if data is None: + return dict() + try: + result = get_crl_info(self.module, data) + result['can_parse_crl'] = True + return result + except Exception as exc: + return dict(can_parse_crl=False) + + def remove(self): + if self.backup: + self.backup_file = self.module.backup_local(self.path) + super(CRL, self).remove(self.module) + + def _compress_entry(self, entry): + issuer = None + if entry['issuer'] is not None: + # Normalize to IDNA. If this is used-provided, it was already converted to + # IDNA (by cryptography_get_name) and thus the `idna` library is present. + # If this is coming from cryptography and isn't already in IDNA (i.e. ascii), + # cryptography < 2.1 must be in use, which depends on `idna`. So this should + # not require `idna` except if it was already used by code earlier during + # this invocation. + issuer = tuple(cryptography_decode_name(issuer, idn_rewrite='idna') for issuer in entry['issuer']) + if self.ignore_timestamps: + # Throw out revocation_date + return ( + entry['serial_number'], + issuer, + entry['issuer_critical'], + entry['reason'], + entry['reason_critical'], + entry['invalidity_date'], + entry['invalidity_date_critical'], + ) + else: + return ( + entry['serial_number'], + entry['revocation_date'], + issuer, + entry['issuer_critical'], + entry['reason'], + entry['reason_critical'], + entry['invalidity_date'], + entry['invalidity_date_critical'], + ) + + def check(self, module, perms_required=True, ignore_conversion=True): + """Ensure the resource is in its desired state.""" + + state_and_perms = super(CRL, self).check(self.module, perms_required) + + if not state_and_perms: + return False + + if self.crl is None: + return False + + if self.last_update != self.crl.last_update and not self.ignore_timestamps: + return False + if self.next_update != self.crl.next_update and not self.ignore_timestamps: + return False + if cryptography_key_needs_digest_for_signing(self.privatekey): + if self.crl.signature_hash_algorithm is None or self.digest.name != self.crl.signature_hash_algorithm.name: + return False + else: + if self.crl.signature_hash_algorithm is not None: + return False + + want_issuer = [(cryptography_name_to_oid(entry[0]), entry[1]) for entry in self.issuer] + is_issuer = [(sub.oid, sub.value) for sub in self.crl.issuer] + if not self.issuer_ordered: + want_issuer = set(want_issuer) + is_issuer = set(is_issuer) + if want_issuer != is_issuer: + return False + + old_entries = [self._compress_entry(cryptography_decode_revoked_certificate(cert)) for cert in self.crl] + new_entries = [self._compress_entry(cert) for cert in self.revoked_certificates] + if self.update: + # We do not simply use a set so that duplicate entries are treated correctly + for entry in new_entries: + try: + old_entries.remove(entry) + except ValueError: + return False + else: + if old_entries != new_entries: + return False + + if self.format != self.actual_format and not ignore_conversion: + return False + + return True + + def _generate_crl(self): + backend = default_backend() + crl = CertificateRevocationListBuilder() + + try: + crl = crl.issuer_name(Name([ + NameAttribute(cryptography_name_to_oid(entry[0]), to_text(entry[1])) + for entry in self.issuer + ])) + except ValueError as e: + raise CRLError(e) + + crl = crl.last_update(self.last_update) + crl = crl.next_update(self.next_update) + + if self.update and self.crl: + new_entries = set([self._compress_entry(entry) for entry in self.revoked_certificates]) + for entry in self.crl: + decoded_entry = self._compress_entry(cryptography_decode_revoked_certificate(entry)) + if decoded_entry not in new_entries: + crl = crl.add_revoked_certificate(entry) + for entry in self.revoked_certificates: + revoked_cert = RevokedCertificateBuilder() + revoked_cert = revoked_cert.serial_number(entry['serial_number']) + revoked_cert = revoked_cert.revocation_date(entry['revocation_date']) + if entry['issuer'] is not None: + revoked_cert = revoked_cert.add_extension( + x509.CertificateIssuer(entry['issuer']), + entry['issuer_critical'] + ) + if entry['reason'] is not None: + revoked_cert = revoked_cert.add_extension( + x509.CRLReason(entry['reason']), + entry['reason_critical'] + ) + if entry['invalidity_date'] is not None: + revoked_cert = revoked_cert.add_extension( + x509.InvalidityDate(entry['invalidity_date']), + entry['invalidity_date_critical'] + ) + crl = crl.add_revoked_certificate(revoked_cert.build(backend)) + + digest = None + if cryptography_key_needs_digest_for_signing(self.privatekey): + digest = self.digest + self.crl = crl.sign(self.privatekey, digest, backend=backend) + if self.format == 'pem': + return self.crl.public_bytes(Encoding.PEM) + else: + return self.crl.public_bytes(Encoding.DER) + + def generate(self): + result = None + if not self.check(self.module, perms_required=False, ignore_conversion=True) or self.force: + result = self._generate_crl() + elif not self.check(self.module, perms_required=False, ignore_conversion=False) and self.crl: + if self.format == 'pem': + result = self.crl.public_bytes(Encoding.PEM) + else: + result = self.crl.public_bytes(Encoding.DER) + + if result is not None: + self.diff_after = self._get_info(result) + if self.return_content: + if self.format == 'pem': + self.crl_content = result + else: + self.crl_content = base64.b64encode(result) + if self.backup: + self.backup_file = self.module.backup_local(self.path) + write_file(self.module, result) + self.changed = True + + file_args = self.module.load_file_common_arguments(self.module.params) + if self.module.check_file_absent_if_check_mode(file_args['path']): + self.changed = True + elif self.module.set_fs_attributes_if_different(file_args, False): + self.changed = True + + def dump(self, check_mode=False): + result = { + 'changed': self.changed, + 'filename': self.path, + 'privatekey': self.privatekey_path, + 'format': self.format, + 'last_update': None, + 'next_update': None, + 'digest': None, + 'issuer_ordered': None, + 'issuer': None, + 'revoked_certificates': [], + } + if self.backup_file: + result['backup_file'] = self.backup_file + + if check_mode: + result['last_update'] = self.last_update.strftime(TIMESTAMP_FORMAT) + result['next_update'] = self.next_update.strftime(TIMESTAMP_FORMAT) + # result['digest'] = cryptography_oid_to_name(self.crl.signature_algorithm_oid) + result['digest'] = self.module.params['digest'] + result['issuer_ordered'] = self.issuer + result['issuer'] = {} + for k, v in self.issuer: + result['issuer'][k] = v + result['revoked_certificates'] = [] + for entry in self.revoked_certificates: + result['revoked_certificates'].append(cryptography_dump_revoked(entry, idn_rewrite=self.name_encoding)) + elif self.crl: + result['last_update'] = self.crl.last_update.strftime(TIMESTAMP_FORMAT) + result['next_update'] = self.crl.next_update.strftime(TIMESTAMP_FORMAT) + result['digest'] = cryptography_oid_to_name(cryptography_get_signature_algorithm_oid_from_crl(self.crl)) + issuer = [] + for attribute in self.crl.issuer: + issuer.append([cryptography_oid_to_name(attribute.oid), attribute.value]) + result['issuer_ordered'] = issuer + result['issuer'] = {} + for k, v in issuer: + result['issuer'][k] = v + result['revoked_certificates'] = [] + for cert in self.crl: + entry = cryptography_decode_revoked_certificate(cert) + result['revoked_certificates'].append(cryptography_dump_revoked(entry, idn_rewrite=self.name_encoding)) + + if self.return_content: + result['crl'] = self.crl_content + + result['diff'] = dict( + before=self.diff_before, + after=self.diff_after, + ) + return result + + +def main(): + module = AnsibleModule( + argument_spec=dict( + state=dict(type='str', default='present', choices=['present', 'absent']), + mode=dict(type='str', default='generate', choices=['generate', 'update']), + force=dict(type='bool', default=False), + backup=dict(type='bool', default=False), + path=dict(type='path', required=True), + format=dict(type='str', default='pem', choices=['pem', 'der']), + privatekey_path=dict(type='path'), + privatekey_content=dict(type='str', no_log=True), + privatekey_passphrase=dict(type='str', no_log=True), + issuer=dict(type='dict'), + issuer_ordered=dict(type='list', elements='dict'), + last_update=dict(type='str', default='+0s'), + next_update=dict(type='str'), + digest=dict(type='str', default='sha256'), + ignore_timestamps=dict(type='bool', default=False), + return_content=dict(type='bool', default=False), + revoked_certificates=dict( + type='list', + elements='dict', + options=dict( + path=dict(type='path'), + content=dict(type='str'), + serial_number=dict(type='int'), + revocation_date=dict(type='str', default='+0s'), + issuer=dict(type='list', elements='str'), + issuer_critical=dict(type='bool', default=False), + reason=dict( + type='str', + choices=[ + 'unspecified', 'key_compromise', 'ca_compromise', 'affiliation_changed', + 'superseded', 'cessation_of_operation', 'certificate_hold', + 'privilege_withdrawn', 'aa_compromise', 'remove_from_crl' + ] + ), + reason_critical=dict(type='bool', default=False), + invalidity_date=dict(type='str'), + invalidity_date_critical=dict(type='bool', default=False), + ), + required_one_of=[['path', 'content', 'serial_number']], + mutually_exclusive=[['path', 'content', 'serial_number']], + ), + name_encoding=dict(type='str', default='ignore', choices=['ignore', 'idna', 'unicode']), + ), + required_if=[ + ('state', 'present', ['privatekey_path', 'privatekey_content'], True), + ('state', 'present', ['issuer', 'issuer_ordered'], True), + ('state', 'present', ['next_update', 'revoked_certificates'], False), + ], + mutually_exclusive=( + ['privatekey_path', 'privatekey_content'], + ['issuer', 'issuer_ordered'], + ), + supports_check_mode=True, + add_file_common_args=True, + ) + + if not CRYPTOGRAPHY_FOUND: + module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)), + exception=CRYPTOGRAPHY_IMP_ERR) + + try: + crl = CRL(module) + + if module.params['state'] == 'present': + if module.check_mode: + result = crl.dump(check_mode=True) + result['changed'] = module.params['force'] or not crl.check(module) or not crl.check(module, ignore_conversion=False) + module.exit_json(**result) + + crl.generate() + else: + if module.check_mode: + result = crl.dump(check_mode=True) + result['changed'] = os.path.exists(module.params['path']) + module.exit_json(**result) + + crl.remove() + + result = crl.dump() + module.exit_json(**result) + except OpenSSLObjectError as exc: + module.fail_json(msg=to_native(exc)) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/crypto/plugins/modules/x509_crl_info.py b/ansible_collections/community/crypto/plugins/modules/x509_crl_info.py new file mode 100644 index 00000000..7b0ebcac --- /dev/null +++ b/ansible_collections/community/crypto/plugins/modules/x509_crl_info.py @@ -0,0 +1,220 @@ +#!/usr/bin/python +# -*- 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 + + +DOCUMENTATION = r''' +--- +module: x509_crl_info +version_added: '1.0.0' +short_description: Retrieve information on Certificate Revocation Lists (CRLs) +description: + - This module allows one to retrieve information on Certificate Revocation Lists (CRLs). +requirements: + - cryptography >= 1.2 +author: + - Felix Fontein (@felixfontein) +extends_documentation_fragment: + - community.crypto.attributes + - community.crypto.attributes.info_module + - community.crypto.name_encoding +options: + path: + description: + - Remote absolute path where the generated CRL file should be created or is already located. + - Either I(path) or I(content) must be specified, but not both. + type: path + content: + description: + - Content of the X.509 CRL in PEM format, or Base64-encoded X.509 CRL. + - Either I(path) or I(content) must be specified, but not both. + type: str + list_revoked_certificates: + description: + - If set to C(false), the list of revoked certificates is not included in the result. + - This is useful when retrieving information on large CRL files. Enumerating all revoked + certificates can take some time, including serializing the result as JSON, sending it to + the Ansible controller, and decoding it again. + type: bool + default: true + version_added: 1.7.0 + +notes: + - All timestamp values are provided in ASN.1 TIME format, in other words, following the C(YYYYMMDDHHMMSSZ) pattern. + They are all in UTC. +seealso: + - module: community.crypto.x509_crl + - ref: community.crypto.x509_crl_info filter <ansible_collections.community.crypto.x509_crl_info_filter> + # - plugin: community.crypto.x509_crl_info + # plugin_type: filter + description: A filter variant of this module. +''' + +EXAMPLES = r''' +- name: Get information on CRL + community.crypto.x509_crl_info: + path: /etc/ssl/my-ca.crl + register: result + +- name: Print the information + ansible.builtin.debug: + msg: "{{ result }}" + +- name: Get information on CRL without list of revoked certificates + community.crypto.x509_crl_info: + path: /etc/ssl/very-large.crl + list_revoked_certificates: false + register: result +''' + +RETURN = r''' +format: + description: + - Whether the CRL is in PEM format (C(pem)) or in DER format (C(der)). + returned: success + type: str + sample: pem +issuer: + description: + - The CRL's issuer. + - Note that for repeated values, only the last one will be returned. + - See I(name_encoding) for how IDNs are handled. + returned: success + type: dict + sample: {"organizationName": "Ansible", "commonName": "ca.example.com"} +issuer_ordered: + description: The CRL's issuer as an ordered list of tuples. + returned: success + type: list + elements: list + sample: [["organizationName", "Ansible"], ["commonName": "ca.example.com"]] +last_update: + description: The point in time from which this CRL can be trusted as ASN.1 TIME. + returned: success + type: str + sample: '20190413202428Z' +next_update: + description: The point in time from which a new CRL will be issued and the client has to check for it as ASN.1 TIME. + returned: success + type: str + sample: '20190413202428Z' +digest: + description: The signature algorithm used to sign the CRL. + returned: success + type: str + sample: sha256WithRSAEncryption +revoked_certificates: + description: List of certificates to be revoked. + returned: success if I(list_revoked_certificates=true) + type: list + elements: dict + contains: + serial_number: + description: Serial number of the certificate. + type: int + sample: 1234 + revocation_date: + description: The point in time the certificate was revoked as ASN.1 TIME. + type: str + sample: '20190413202428Z' + issuer: + description: + - The certificate's issuer. + - See I(name_encoding) for how IDNs are handled. + type: list + elements: str + sample: ["DNS:ca.example.org"] + issuer_critical: + description: Whether the certificate issuer extension is critical. + type: bool + sample: false + reason: + description: + - The value for the revocation reason extension. + - One of C(unspecified), C(key_compromise), C(ca_compromise), C(affiliation_changed), C(superseded), + C(cessation_of_operation), C(certificate_hold), C(privilege_withdrawn), C(aa_compromise), and + C(remove_from_crl). + type: str + sample: key_compromise + reason_critical: + description: Whether the revocation reason extension is critical. + type: bool + sample: false + invalidity_date: + description: | + The point in time it was known/suspected that the private key was compromised + or that the certificate otherwise became invalid as ASN.1 TIME. + type: str + sample: '20190413202428Z' + invalidity_date_critical: + description: Whether the invalidity date extension is critical. + type: bool + sample: false +''' + + +import base64 +import binascii + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.common.text.converters import to_native + +from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import ( + OpenSSLObjectError, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.pem import ( + identify_pem_format, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.crl_info import ( + get_crl_info, +) + + +def main(): + module = AnsibleModule( + argument_spec=dict( + path=dict(type='path'), + content=dict(type='str'), + list_revoked_certificates=dict(type='bool', default=True), + name_encoding=dict(type='str', default='ignore', choices=['ignore', 'idna', 'unicode']), + ), + required_one_of=( + ['path', 'content'], + ), + mutually_exclusive=( + ['path', 'content'], + ), + supports_check_mode=True, + ) + + if module.params['content'] is None: + try: + with open(module.params['path'], 'rb') as f: + data = f.read() + except (IOError, OSError) as e: + module.fail_json(msg='Error while reading CRL file from disk: {0}'.format(e)) + else: + data = module.params['content'].encode('utf-8') + if not identify_pem_format(data): + try: + data = base64.b64decode(module.params['content']) + except (binascii.Error, TypeError) as e: + module.fail_json(msg='Error while Base64 decoding content: {0}'.format(e)) + + try: + result = get_crl_info(module, data, list_revoked_certificates=module.params['list_revoked_certificates']) + module.exit_json(**result) + except OpenSSLObjectError as e: + module.fail_json(msg=to_native(e)) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/crypto/plugins/plugin_utils/action_module.py b/ansible_collections/community/crypto/plugins/plugin_utils/action_module.py new file mode 100644 index 00000000..3d7a77b2 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/plugin_utils/action_module.py @@ -0,0 +1,765 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2012-2013 Michael DeHaan <michael.dehaan@gmail.com> +# Copyright (c) 2016 Toshio Kuratomi <tkuratomi@ansible.com> +# Copyright (c) 2019 Ansible Project +# Copyright (c) 2020 Felix Fontein <felix@fontein.de> +# Copyright (c) 2021 Ansible Project +# 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 + +# Parts taken from ansible.module_utils.basic and ansible.module_utils.common.warnings. + +# NOTE: THIS IS ONLY FOR ACTION PLUGINS! + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +import abc +import copy +import traceback + +from ansible import constants as C +from ansible.errors import AnsibleError +from ansible.module_utils import six +from ansible.module_utils.basic import AnsibleFallbackNotFound, SEQUENCETYPE, remove_values +from ansible.module_utils.common._collections_compat import ( + Mapping +) +from ansible.module_utils.common.parameters import ( + PASS_VARS, + PASS_BOOLS, +) +from ansible.module_utils.common.validation import ( + check_mutually_exclusive, + check_required_arguments, + check_required_by, + check_required_if, + check_required_one_of, + check_required_together, + count_terms, + check_type_bool, + check_type_bits, + check_type_bytes, + check_type_float, + check_type_int, + check_type_jsonarg, + check_type_list, + check_type_dict, + check_type_path, + check_type_raw, + check_type_str, + safe_eval, +) +from ansible.module_utils.common.text.formatters import ( + lenient_lowercase, +) +from ansible.module_utils.parsing.convert_bool import BOOLEANS_FALSE, BOOLEANS_TRUE +from ansible.module_utils.six import ( + binary_type, + string_types, + text_type, +) +from ansible.module_utils.common.text.converters import to_native, to_text +from ansible.plugins.action import ActionBase + + +try: + # For ansible-core 2.11, we can use the ArgumentSpecValidator. We also import + # ModuleArgumentSpecValidator since that indicates that the 'classical' approach + # will no longer work. + from ansible.module_utils.common.arg_spec import ( # noqa: F401, pylint: disable=unused-import + ArgumentSpecValidator, + ModuleArgumentSpecValidator, # ModuleArgumentSpecValidator is not used + ) + from ansible.module_utils.errors import UnsupportedError + HAS_ARGSPEC_VALIDATOR = True +except ImportError: + # For ansible-base 2.10 and Ansible 2.9, we need to use the 'classical' approach + from ansible.module_utils.common.parameters import ( + handle_aliases, + list_deprecations, + list_no_log_values, + ) + HAS_ARGSPEC_VALIDATOR = False + + +class _ModuleExitException(Exception): + def __init__(self, result): + super(_ModuleExitException, self).__init__() + self.result = result + + +class AnsibleActionModule(object): + def __init__(self, action_plugin, argument_spec, bypass_checks=False, + mutually_exclusive=None, required_together=None, + required_one_of=None, supports_check_mode=False, + required_if=None, required_by=None): + # Internal data + self.__action_plugin = action_plugin + self.__warnings = [] + self.__deprecations = [] + + # AnsibleModule data + self._name = self.__action_plugin._task.action + self.argument_spec = argument_spec + self.supports_check_mode = supports_check_mode + self.check_mode = self.__action_plugin._play_context.check_mode + self.bypass_checks = bypass_checks + self.no_log = self.__action_plugin._play_context.no_log + + self.mutually_exclusive = mutually_exclusive + self.required_together = required_together + self.required_one_of = required_one_of + self.required_if = required_if + self.required_by = required_by + self._diff = self.__action_plugin._play_context.diff + self._verbosity = self.__action_plugin._display.verbosity + + self.aliases = {} + self._legal_inputs = [] + self._options_context = list() + + self.params = copy.deepcopy(self.__action_plugin._task.args) + self.no_log_values = set() + if HAS_ARGSPEC_VALIDATOR: + self._validator = ArgumentSpecValidator( + self.argument_spec, + self.mutually_exclusive, + self.required_together, + self.required_one_of, + self.required_if, + self.required_by, + ) + self._validation_result = self._validator.validate(self.params) + self.params.update(self._validation_result.validated_parameters) + self.no_log_values.update(self._validation_result._no_log_values) + + try: + error = self._validation_result.errors[0] + except IndexError: + error = None + + # We cannot use ModuleArgumentSpecValidator directly since it uses mechanisms for reporting + # warnings and deprecations that do not work in plugins. This is a copy of that code adjusted + # for our use-case: + for d in self._validation_result._deprecations: + # Before ansible-core 2.14.2, deprecations were always for aliases: + if 'name' in d: + self.deprecate( + "Alias '{name}' is deprecated. See the module docs for more information".format(name=d['name']), + version=d.get('version'), date=d.get('date'), collection_name=d.get('collection_name')) + # Since ansible-core 2.14.2, a message is present that can be directly printed: + if 'msg' in d: + self.deprecate(d['msg'], version=d.get('version'), date=d.get('date'), collection_name=d.get('collection_name')) + + for w in self._validation_result._warnings: + self.warn('Both option {option} and its alias {alias} are set.'.format(option=w['option'], alias=w['alias'])) + + # Fail for validation errors, even in check mode + if error: + msg = self._validation_result.errors.msg + if isinstance(error, UnsupportedError): + msg = "Unsupported parameters for ({name}) {kind}: {msg}".format(name=self._name, kind='module', msg=msg) + + self.fail_json(msg=msg) + else: + self._set_fallbacks() + + # append to legal_inputs and then possibly check against them + try: + self.aliases = self._handle_aliases() + except (ValueError, TypeError) as e: + # Use exceptions here because it is not safe to call fail_json until no_log is processed + raise _ModuleExitException(dict(failed=True, msg="Module alias error: %s" % to_native(e))) + + # Save parameter values that should never be logged + self._handle_no_log_values() + + self._check_arguments() + + # check exclusive early + if not bypass_checks: + self._check_mutually_exclusive(mutually_exclusive) + + self._set_defaults(pre=True) + + self._CHECK_ARGUMENT_TYPES_DISPATCHER = { + 'str': self._check_type_str, + 'list': check_type_list, + 'dict': check_type_dict, + 'bool': check_type_bool, + 'int': check_type_int, + 'float': check_type_float, + 'path': check_type_path, + 'raw': check_type_raw, + 'jsonarg': check_type_jsonarg, + 'json': check_type_jsonarg, + 'bytes': check_type_bytes, + 'bits': check_type_bits, + } + if not bypass_checks: + self._check_required_arguments() + self._check_argument_types() + self._check_argument_values() + self._check_required_together(required_together) + self._check_required_one_of(required_one_of) + self._check_required_if(required_if) + self._check_required_by(required_by) + + self._set_defaults(pre=False) + + # deal with options sub-spec + self._handle_options() + + def _handle_aliases(self, spec=None, param=None, option_prefix=''): + if spec is None: + spec = self.argument_spec + if param is None: + param = self.params + + # this uses exceptions as it happens before we can safely call fail_json + alias_warnings = [] + alias_results, self._legal_inputs = handle_aliases(spec, param, alias_warnings=alias_warnings) # pylint: disable=used-before-assignment + for option, alias in alias_warnings: + self.warn('Both option %s and its alias %s are set.' % (option_prefix + option, option_prefix + alias)) + + deprecated_aliases = [] + for i in spec.keys(): + if 'deprecated_aliases' in spec[i].keys(): + for alias in spec[i]['deprecated_aliases']: + deprecated_aliases.append(alias) + + for deprecation in deprecated_aliases: + if deprecation['name'] in param.keys(): + self.deprecate("Alias '%s' is deprecated. See the module docs for more information" % deprecation['name'], + version=deprecation.get('version'), date=deprecation.get('date'), + collection_name=deprecation.get('collection_name')) + return alias_results + + def _handle_no_log_values(self, spec=None, param=None): + if spec is None: + spec = self.argument_spec + if param is None: + param = self.params + + try: + self.no_log_values.update(list_no_log_values(spec, param)) # pylint: disable=used-before-assignment + except TypeError as te: + self.fail_json(msg="Failure when processing no_log parameters. Module invocation will be hidden. " + "%s" % to_native(te), invocation={'module_args': 'HIDDEN DUE TO FAILURE'}) + + for message in list_deprecations(spec, param): # pylint: disable=used-before-assignment + self.deprecate(message['msg'], version=message.get('version'), date=message.get('date'), + collection_name=message.get('collection_name')) + + def _check_arguments(self, spec=None, param=None, legal_inputs=None): + self._syslog_facility = 'LOG_USER' + unsupported_parameters = set() + if spec is None: + spec = self.argument_spec + if param is None: + param = self.params + if legal_inputs is None: + legal_inputs = self._legal_inputs + + for k in list(param.keys()): + + if k not in legal_inputs: + unsupported_parameters.add(k) + + for k in PASS_VARS: + # handle setting internal properties from internal ansible vars + param_key = '_ansible_%s' % k + if param_key in param: + if k in PASS_BOOLS: + setattr(self, PASS_VARS[k][0], self.boolean(param[param_key])) + else: + setattr(self, PASS_VARS[k][0], param[param_key]) + + # clean up internal top level params: + if param_key in self.params: + del self.params[param_key] + else: + # use defaults if not already set + if not hasattr(self, PASS_VARS[k][0]): + setattr(self, PASS_VARS[k][0], PASS_VARS[k][1]) + + if unsupported_parameters: + msg = "Unsupported parameters for (%s) module: %s" % (self._name, ', '.join(sorted(list(unsupported_parameters)))) + if self._options_context: + msg += " found in %s." % " -> ".join(self._options_context) + supported_parameters = list() + for key in sorted(spec.keys()): + if 'aliases' in spec[key] and spec[key]['aliases']: + supported_parameters.append("%s (%s)" % (key, ', '.join(sorted(spec[key]['aliases'])))) + else: + supported_parameters.append(key) + msg += " Supported parameters include: %s" % (', '.join(supported_parameters)) + self.fail_json(msg=msg) + if self.check_mode and not self.supports_check_mode: + self.exit_json(skipped=True, msg="action module (%s) does not support check mode" % self._name) + + def _count_terms(self, check, param=None): + if param is None: + param = self.params + return count_terms(check, param) + + def _check_mutually_exclusive(self, spec, param=None): + if param is None: + param = self.params + + try: + check_mutually_exclusive(spec, param) + except TypeError as e: + msg = to_native(e) + if self._options_context: + msg += " found in %s" % " -> ".join(self._options_context) + self.fail_json(msg=msg) + + def _check_required_one_of(self, spec, param=None): + if spec is None: + return + + if param is None: + param = self.params + + try: + check_required_one_of(spec, param) + except TypeError as e: + msg = to_native(e) + if self._options_context: + msg += " found in %s" % " -> ".join(self._options_context) + self.fail_json(msg=msg) + + def _check_required_together(self, spec, param=None): + if spec is None: + return + if param is None: + param = self.params + + try: + check_required_together(spec, param) + except TypeError as e: + msg = to_native(e) + if self._options_context: + msg += " found in %s" % " -> ".join(self._options_context) + self.fail_json(msg=msg) + + def _check_required_by(self, spec, param=None): + if spec is None: + return + if param is None: + param = self.params + + try: + check_required_by(spec, param) + except TypeError as e: + self.fail_json(msg=to_native(e)) + + def _check_required_arguments(self, spec=None, param=None): + if spec is None: + spec = self.argument_spec + if param is None: + param = self.params + + try: + check_required_arguments(spec, param) + except TypeError as e: + msg = to_native(e) + if self._options_context: + msg += " found in %s" % " -> ".join(self._options_context) + self.fail_json(msg=msg) + + def _check_required_if(self, spec, param=None): + ''' ensure that parameters which conditionally required are present ''' + if spec is None: + return + if param is None: + param = self.params + + try: + check_required_if(spec, param) + except TypeError as e: + msg = to_native(e) + if self._options_context: + msg += " found in %s" % " -> ".join(self._options_context) + self.fail_json(msg=msg) + + def _check_argument_values(self, spec=None, param=None): + ''' ensure all arguments have the requested values, and there are no stray arguments ''' + if spec is None: + spec = self.argument_spec + if param is None: + param = self.params + for (k, v) in spec.items(): + choices = v.get('choices', None) + if choices is None: + continue + if isinstance(choices, SEQUENCETYPE) and not isinstance(choices, (binary_type, text_type)): + if k in param: + # Allow one or more when type='list' param with choices + if isinstance(param[k], list): + diff_list = ", ".join([item for item in param[k] if item not in choices]) + if diff_list: + choices_str = ", ".join([to_native(c) for c in choices]) + msg = "value of %s must be one or more of: %s. Got no match for: %s" % (k, choices_str, diff_list) + if self._options_context: + msg += " found in %s" % " -> ".join(self._options_context) + self.fail_json(msg=msg) + elif param[k] not in choices: + # PyYaml converts certain strings to bools. If we can unambiguously convert back, do so before checking + # the value. If we cannot figure this out, module author is responsible. + lowered_choices = None + if param[k] == 'False': + lowered_choices = lenient_lowercase(choices) + overlap = BOOLEANS_FALSE.intersection(choices) + if len(overlap) == 1: + # Extract from a set + (param[k],) = overlap + + if param[k] == 'True': + if lowered_choices is None: + lowered_choices = lenient_lowercase(choices) + overlap = BOOLEANS_TRUE.intersection(choices) + if len(overlap) == 1: + (param[k],) = overlap + + if param[k] not in choices: + choices_str = ", ".join([to_native(c) for c in choices]) + msg = "value of %s must be one of: %s, got: %s" % (k, choices_str, param[k]) + if self._options_context: + msg += " found in %s" % " -> ".join(self._options_context) + self.fail_json(msg=msg) + else: + msg = "internal error: choices for argument %s are not iterable: %s" % (k, choices) + if self._options_context: + msg += " found in %s" % " -> ".join(self._options_context) + self.fail_json(msg=msg) + + def safe_eval(self, value, locals=None, include_exceptions=False): + return safe_eval(value, locals, include_exceptions) + + def _check_type_str(self, value, param=None, prefix=''): + opts = { + 'error': False, + 'warn': False, + 'ignore': True + } + + # Ignore, warn, or error when converting to a string. + allow_conversion = opts.get(C.STRING_CONVERSION_ACTION, True) + try: + return check_type_str(value, allow_conversion) + except TypeError: + common_msg = 'quote the entire value to ensure it does not change.' + from_msg = '{0!r}'.format(value) + to_msg = '{0!r}'.format(to_text(value)) + + if param is not None: + if prefix: + param = '{0}{1}'.format(prefix, param) + + from_msg = '{0}: {1!r}'.format(param, value) + to_msg = '{0}: {1!r}'.format(param, to_text(value)) + + if C.STRING_CONVERSION_ACTION == 'error': + msg = common_msg.capitalize() + raise TypeError(to_native(msg)) + elif C.STRING_CONVERSION_ACTION == 'warn': + msg = ('The value "{0}" (type {1.__class__.__name__}) was converted to "{2}" (type string). ' + 'If this does not look like what you expect, {3}').format(from_msg, value, to_msg, common_msg) + self.warn(to_native(msg)) + return to_native(value, errors='surrogate_or_strict') + + def _handle_options(self, argument_spec=None, params=None, prefix=''): + ''' deal with options to create sub spec ''' + if argument_spec is None: + argument_spec = self.argument_spec + if params is None: + params = self.params + + for (k, v) in argument_spec.items(): + wanted = v.get('type', None) + if wanted == 'dict' or (wanted == 'list' and v.get('elements', '') == 'dict'): + spec = v.get('options', None) + if v.get('apply_defaults', False): + if spec is not None: + if params.get(k) is None: + params[k] = {} + else: + continue + elif spec is None or k not in params or params[k] is None: + continue + + self._options_context.append(k) + + if isinstance(params[k], dict): + elements = [params[k]] + else: + elements = params[k] + + for idx, param in enumerate(elements): + if not isinstance(param, dict): + self.fail_json(msg="value of %s must be of type dict or list of dict" % k) + + new_prefix = prefix + k + if wanted == 'list': + new_prefix += '[%d]' % idx + new_prefix += '.' + + self._set_fallbacks(spec, param) + options_aliases = self._handle_aliases(spec, param, option_prefix=new_prefix) + + options_legal_inputs = list(spec.keys()) + list(options_aliases.keys()) + + self._check_arguments(spec, param, options_legal_inputs) + + # check exclusive early + if not self.bypass_checks: + self._check_mutually_exclusive(v.get('mutually_exclusive', None), param) + + self._set_defaults(pre=True, spec=spec, param=param) + + if not self.bypass_checks: + self._check_required_arguments(spec, param) + self._check_argument_types(spec, param, new_prefix) + self._check_argument_values(spec, param) + + self._check_required_together(v.get('required_together', None), param) + self._check_required_one_of(v.get('required_one_of', None), param) + self._check_required_if(v.get('required_if', None), param) + self._check_required_by(v.get('required_by', None), param) + + self._set_defaults(pre=False, spec=spec, param=param) + + # handle multi level options (sub argspec) + self._handle_options(spec, param, new_prefix) + self._options_context.pop() + + def _get_wanted_type(self, wanted, k): + if not callable(wanted): + if wanted is None: + # Mostly we want to default to str. + # For values set to None explicitly, return None instead as + # that allows a user to unset a parameter + wanted = 'str' + try: + type_checker = self._CHECK_ARGUMENT_TYPES_DISPATCHER[wanted] + except KeyError: + self.fail_json(msg="implementation error: unknown type %s requested for %s" % (wanted, k)) + else: + # set the type_checker to the callable, and reset wanted to the callable's name (or type if it does not have one, ala MagicMock) + type_checker = wanted + wanted = getattr(wanted, '__name__', to_native(type(wanted))) + + return type_checker, wanted + + def _handle_elements(self, wanted, param, values): + type_checker, wanted_name = self._get_wanted_type(wanted, param) + validated_params = [] + # Get param name for strings so we can later display this value in a useful error message if needed + # Only pass 'kwargs' to our checkers and ignore custom callable checkers + kwargs = {} + if wanted_name == 'str' and isinstance(wanted, string_types): + if isinstance(param, string_types): + kwargs['param'] = param + elif isinstance(param, dict): + kwargs['param'] = list(param.keys())[0] + for value in values: + try: + validated_params.append(type_checker(value, **kwargs)) + except (TypeError, ValueError) as e: + msg = "Elements value for option %s" % param + if self._options_context: + msg += " found in '%s'" % " -> ".join(self._options_context) + msg += " is of type %s and we were unable to convert to %s: %s" % (type(value), wanted_name, to_native(e)) + self.fail_json(msg=msg) + return validated_params + + def _check_argument_types(self, spec=None, param=None, prefix=''): + ''' ensure all arguments have the requested type ''' + + if spec is None: + spec = self.argument_spec + if param is None: + param = self.params + + for (k, v) in spec.items(): + wanted = v.get('type', None) + if k not in param: + continue + + value = param[k] + if value is None: + continue + + type_checker, wanted_name = self._get_wanted_type(wanted, k) + # Get param name for strings so we can later display this value in a useful error message if needed + # Only pass 'kwargs' to our checkers and ignore custom callable checkers + kwargs = {} + if wanted_name == 'str' and isinstance(type_checker, string_types): + kwargs['param'] = list(param.keys())[0] + + # Get the name of the parent key if this is a nested option + if prefix: + kwargs['prefix'] = prefix + + try: + param[k] = type_checker(value, **kwargs) + wanted_elements = v.get('elements', None) + if wanted_elements: + if wanted != 'list' or not isinstance(param[k], list): + msg = "Invalid type %s for option '%s'" % (wanted_name, param) + if self._options_context: + msg += " found in '%s'." % " -> ".join(self._options_context) + msg += ", elements value check is supported only with 'list' type" + self.fail_json(msg=msg) + param[k] = self._handle_elements(wanted_elements, k, param[k]) + + except (TypeError, ValueError) as e: + msg = "argument %s is of type %s" % (k, type(value)) + if self._options_context: + msg += " found in '%s'." % " -> ".join(self._options_context) + msg += " and we were unable to convert to %s: %s" % (wanted_name, to_native(e)) + self.fail_json(msg=msg) + + def _set_defaults(self, pre=True, spec=None, param=None): + if spec is None: + spec = self.argument_spec + if param is None: + param = self.params + for (k, v) in spec.items(): + default = v.get('default', None) + if pre is True: + # this prevents setting defaults on required items + if default is not None and k not in param: + param[k] = default + else: + # make sure things without a default still get set None + if k not in param: + param[k] = default + + def _set_fallbacks(self, spec=None, param=None): + if spec is None: + spec = self.argument_spec + if param is None: + param = self.params + + for (k, v) in spec.items(): + fallback = v.get('fallback', (None,)) + fallback_strategy = fallback[0] + fallback_args = [] + fallback_kwargs = {} + if k not in param and fallback_strategy is not None: + for item in fallback[1:]: + if isinstance(item, dict): + fallback_kwargs = item + else: + fallback_args = item + try: + param[k] = fallback_strategy(*fallback_args, **fallback_kwargs) + except AnsibleFallbackNotFound: + continue + + def warn(self, warning): + # Copied from ansible.module_utils.common.warnings: + if isinstance(warning, string_types): + self.__warnings.append(warning) + else: + raise TypeError("warn requires a string not a %s" % type(warning)) + + def deprecate(self, msg, version=None, date=None, collection_name=None): + if version is not None and date is not None: + raise AssertionError("implementation error -- version and date must not both be set") + + # Copied from ansible.module_utils.common.warnings: + if isinstance(msg, string_types): + # For compatibility, we accept that neither version nor date is set, + # and treat that the same as if version would haven been set + if date is not None: + self.__deprecations.append({'msg': msg, 'date': date, 'collection_name': collection_name}) + else: + self.__deprecations.append({'msg': msg, 'version': version, 'collection_name': collection_name}) + else: + raise TypeError("deprecate requires a string not a %s" % type(msg)) + + def _return_formatted(self, kwargs): + if 'invocation' not in kwargs: + kwargs['invocation'] = {'module_args': self.params} + + if 'warnings' in kwargs: + if isinstance(kwargs['warnings'], list): + for w in kwargs['warnings']: + self.warn(w) + else: + self.warn(kwargs['warnings']) + + if self.__warnings: + kwargs['warnings'] = self.__warnings + + if 'deprecations' in kwargs: + if isinstance(kwargs['deprecations'], list): + for d in kwargs['deprecations']: + if isinstance(d, SEQUENCETYPE) and len(d) == 2: + self.deprecate(d[0], version=d[1]) + elif isinstance(d, Mapping): + self.deprecate(d['msg'], version=d.get('version'), date=d.get('date'), + collection_name=d.get('collection_name')) + else: + self.deprecate(d) # pylint: disable=ansible-deprecated-no-version + else: + self.deprecate(kwargs['deprecations']) # pylint: disable=ansible-deprecated-no-version + + if self.__deprecations: + kwargs['deprecations'] = self.__deprecations + + kwargs = remove_values(kwargs, self.no_log_values) + raise _ModuleExitException(kwargs) + + def exit_json(self, **kwargs): + result = dict(kwargs) + if 'failed' not in result: + result['failed'] = False + self._return_formatted(result) + + def fail_json(self, msg, **kwargs): + result = dict(kwargs) + result['failed'] = True + result['msg'] = msg + self._return_formatted(result) + + +@six.add_metaclass(abc.ABCMeta) +class ActionModuleBase(ActionBase): + @abc.abstractmethod + def setup_module(self): + """Return pair (ArgumentSpec, kwargs).""" + pass + + @abc.abstractmethod + def run_module(self, module): + """Run module code""" + module.fail_json(msg='Not implemented.') + + def run(self, tmp=None, task_vars=None): + if task_vars is None: + task_vars = dict() + + result = super(ActionModuleBase, self).run(tmp, task_vars) + del tmp # tmp no longer has any effect + + try: + argument_spec, kwargs = self.setup_module() + module = argument_spec.create_ansible_module_helper(AnsibleActionModule, (self, ), **kwargs) + self.run_module(module) + raise AnsibleError('Internal error: action module did not call module.exit_json()') + except _ModuleExitException as mee: + result.update(mee.result) + return result + except Exception as dummy: + result['failed'] = True + result['msg'] = 'MODULE FAILURE' + result['exception'] = traceback.format_exc() + return result diff --git a/ansible_collections/community/crypto/plugins/plugin_utils/filter_module.py b/ansible_collections/community/crypto/plugins/plugin_utils/filter_module.py new file mode 100644 index 00000000..ce58317e --- /dev/null +++ b/ansible_collections/community/crypto/plugins/plugin_utils/filter_module.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2022 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 + +# NOTE: THIS IS ONLY FOR FILTER PLUGINS! + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +from ansible.errors import AnsibleFilterError + + +class FilterModuleMock(object): + def __init__(self, params): + self.check_mode = True + self.params = params + self._diff = False + + def fail_json(self, msg, **kwargs): + raise AnsibleFilterError(msg) |