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/modules | |
parent | Initial commit. (diff) | |
download | ansible-upstream.tar.xz ansible-upstream.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/modules')
32 files changed, 13373 insertions, 0 deletions
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() |