summaryrefslogtreecommitdiffstats
path: root/ansible_collections/community/crypto/plugins/modules
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 16:03:42 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 16:03:42 +0000
commit66cec45960ce1d9c794e9399de15c138acb18aed (patch)
tree59cd19d69e9d56b7989b080da7c20ef1a3fe2a5a /ansible_collections/community/crypto/plugins/modules
parentInitial commit. (diff)
downloadansible-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')
-rw-r--r--ansible_collections/community/crypto/plugins/modules/acme_account.py345
-rw-r--r--ansible_collections/community/crypto/plugins/modules/acme_account_info.py320
-rw-r--r--ansible_collections/community/crypto/plugins/modules/acme_certificate.py919
-rw-r--r--ansible_collections/community/crypto/plugins/modules/acme_certificate_revoke.py245
-rw-r--r--ansible_collections/community/crypto/plugins/modules/acme_challenge_cert_helper.py319
-rw-r--r--ansible_collections/community/crypto/plugins/modules/acme_inspect.py325
-rw-r--r--ansible_collections/community/crypto/plugins/modules/certificate_complete_chain.py375
-rw-r--r--ansible_collections/community/crypto/plugins/modules/crypto_info.py337
-rw-r--r--ansible_collections/community/crypto/plugins/modules/ecs_certificate.py966
-rw-r--r--ansible_collections/community/crypto/plugins/modules/ecs_domain.py412
-rw-r--r--ansible_collections/community/crypto/plugins/modules/get_certificate.py397
-rw-r--r--ansible_collections/community/crypto/plugins/modules/luks_device.py1031
-rw-r--r--ansible_collections/community/crypto/plugins/modules/openssh_cert.py578
-rw-r--r--ansible_collections/community/crypto/plugins/modules/openssh_keypair.py244
-rw-r--r--ansible_collections/community/crypto/plugins/modules/openssl_csr.py359
-rw-r--r--ansible_collections/community/crypto/plugins/modules/openssl_csr_info.py359
-rw-r--r--ansible_collections/community/crypto/plugins/modules/openssl_csr_pipe.py183
-rw-r--r--ansible_collections/community/crypto/plugins/modules/openssl_dhparam.py431
-rw-r--r--ansible_collections/community/crypto/plugins/modules/openssl_pkcs12.py848
-rw-r--r--ansible_collections/community/crypto/plugins/modules/openssl_privatekey.py290
-rw-r--r--ansible_collections/community/crypto/plugins/modules/openssl_privatekey_convert.py171
-rw-r--r--ansible_collections/community/crypto/plugins/modules/openssl_privatekey_info.py278
-rw-r--r--ansible_collections/community/crypto/plugins/modules/openssl_privatekey_pipe.py131
-rw-r--r--ansible_collections/community/crypto/plugins/modules/openssl_publickey.py488
-rw-r--r--ansible_collections/community/crypto/plugins/modules/openssl_publickey_info.py217
-rw-r--r--ansible_collections/community/crypto/plugins/modules/openssl_signature.py276
-rw-r--r--ansible_collections/community/crypto/plugins/modules/openssl_signature_info.py299
-rw-r--r--ansible_collections/community/crypto/plugins/modules/x509_certificate.py419
-rw-r--r--ansible_collections/community/crypto/plugins/modules/x509_certificate_info.py466
-rw-r--r--ansible_collections/community/crypto/plugins/modules/x509_certificate_pipe.py211
-rw-r--r--ansible_collections/community/crypto/plugins/modules/x509_crl.py914
-rw-r--r--ansible_collections/community/crypto/plugins/modules/x509_crl_info.py220
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()