#!/usr/bin/python # Copyright (c) 2016, Yanis Guenane # 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 annotations 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. B(OpenSSH private keys are not supported), use the M(community.crypto.openssh_keypair) module to manage these. - The module uses the cryptography Python library. author: - Yanis Guenane (@Spredzy) - Felix Fontein (@felixfontein) extends_documentation_fragment: - ansible.builtin.files - community.crypto._attributes - community.crypto._attributes.files - community.crypto._cryptography_dep.minimum attributes: check_mode: support: full diff_mode: support: full safe_file_operations: support: full idempotent: support: partial details: - The module is not idempotent if O(force=true). 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 O(privatekey_path) or O(privatekey_content) must be specified, but not both. If O(state) is V(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 O(privatekey_path) or O(privatekey_content) must be specified, but not both. If O(state) is V(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 V(auto), which tries to use C(cryptography) if available. - If set to V(cryptography), will try to use the L(cryptography,https://cryptography.io/) library. - Note that with community.crypto 3.0.0, all values behave the same. This option will be deprecated in a later version. We recommend to not set it explicitly. type: str default: auto choices: [auto, cryptography] return_content: description: - If set to V(true), will return the (current or generated) public key's content as RV(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 V(none) if the private key has been provided in O(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 O(backup) is V(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 O(state) is V(present) and O(return_content) is V(true) type: str version_added: '1.0.0' """ import os import typing as t from ansible.module_utils.basic import AnsibleModule from ansible_collections.community.crypto.plugins.module_utils._crypto.basic import ( OpenSSLBadPassphraseError, OpenSSLObjectError, ) from ansible_collections.community.crypto.plugins.module_utils._crypto.module_backends.publickey_info import ( PublicKeyParseError, get_publickey_info, ) from ansible_collections.community.crypto.plugins.module_utils._crypto.support import ( OpenSSLObject, get_fingerprint, load_privatekey, ) from ansible_collections.community.crypto.plugins.module_utils._cryptography_dep import ( COLLECTION_MINIMUM_CRYPTOGRAPHY_VERSION, assert_required_cryptography_version, ) from ansible_collections.community.crypto.plugins.module_utils._io import ( load_file_if_exists, write_file, ) MINIMAL_CRYPTOGRAPHY_VERSION = COLLECTION_MINIMUM_CRYPTOGRAPHY_VERSION try: from cryptography.hazmat.primitives import serialization as crypto_serialization except ImportError: pass if t.TYPE_CHECKING: from cryptography.hazmat.primitives.asymmetric.types import ( # pragma: no cover PrivateKeyTypes, PublicKeyTypes, ) class PublicKeyError(OpenSSLObjectError): pass class PublicKey(OpenSSLObject): def __init__(self, module: AnsibleModule) -> None: super().__init__( path=module.params["path"], state=module.params["state"], force=module.params["force"], check_mode=module.check_mode, ) self.module = module self.format: t.Literal["OpenSSH", "PEM"] = module.params["format"] self.privatekey_path: str | None = module.params["privatekey_path"] privatekey_content: str | None = module.params["privatekey_content"] if privatekey_content is not None: self.privatekey_content: bytes | None = privatekey_content.encode("utf-8") else: self.privatekey_content = None self.privatekey_passphrase: str | None = module.params["privatekey_passphrase"] self.privatekey: PrivateKeyTypes | None = None self.publickey_bytes: bytes | None = None self.return_content: bool = module.params["return_content"] self.fingerprint: dict[str, str] = {} self.backup: bool = module.params["backup"] self.backup_file: str | None = None self.diff_before = self._get_info(None) self.diff_after = self._get_info(None) def _get_info(self, data: bytes | None) -> dict[str, t.Any]: if data is None: return {} result = {"can_parse_key": False} try: result.update( get_publickey_info( module=self.module, content=data, prefer_one_fingerprint=True ) ) result["can_parse_key"] = True except PublicKeyParseError as exc: result.update(exc.result) except Exception: pass return result def _create_publickey(self, module: AnsibleModule) -> bytes: self.privatekey = load_privatekey( path=self.privatekey_path, content=self.privatekey_content, passphrase=self.privatekey_passphrase, ) if self.format == "OpenSSH": return self.privatekey.public_key().public_bytes( crypto_serialization.Encoding.OpenSSH, crypto_serialization.PublicFormat.OpenSSH, ) return self.privatekey.public_key().public_bytes( crypto_serialization.Encoding.PEM, crypto_serialization.PublicFormat.SubjectPublicKeyInfo, ) def generate(self, module: AnsibleModule) -> None: """Generate the public key.""" if self.privatekey_path is not None and not os.path.exists( self.privatekey_path ): raise PublicKeyError( f"The private key {self.privatekey_path} does not exist" ) 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=module, content=publickey_content) self.changed = True except OpenSSLBadPassphraseError as exc: raise PublicKeyError(exc) from exc except (IOError, OSError) as exc: raise PublicKeyError(exc) from exc self.fingerprint = get_fingerprint( path=self.privatekey_path, content=self.privatekey_content, passphrase=self.privatekey_passphrase, ) file_args = module.load_file_common_arguments(module.params) if module.check_file_absent_if_check_mode( file_args["path"] ) or module.set_fs_attributes_if_different(file_args, False): self.changed = True def check(self, module: AnsibleModule, *, perms_required: bool = True) -> bool: """Ensure the resource is in its desired state.""" state_and_perms = super().check(module=module, perms_required=perms_required) def _check_privatekey() -> bool: if self.privatekey_path is not None and not os.path.exists( self.privatekey_path ): return False current_publickey: PublicKeyTypes 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.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 ) 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 ) publickey_content = current_publickey.public_bytes( crypto_serialization.Encoding.PEM, crypto_serialization.PublicFormat.SubjectPublicKeyInfo, ) except Exception: return False try: desired_publickey = self._create_publickey(module) except OpenSSLBadPassphraseError as exc: raise PublicKeyError(exc) from exc return publickey_content == desired_publickey if not state_and_perms: return state_and_perms return _check_privatekey() def remove(self, module: AnsibleModule) -> None: if self.backup: self.backup_file = module.backup_local(self.path) super().remove(module) def dump(self) -> dict[str, t.Any]: """Serialize the object into a dictionary.""" result: dict[str, t.Any] = { "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( path=self.path, ignore_errors=True ) result["publickey"] = ( self.publickey_bytes.decode("utf-8") if self.publickey_bytes else None ) result["diff"] = { "before": self.diff_before, "after": self.diff_after, } return result def main() -> t.NoReturn: module = AnsibleModule( argument_spec={ "state": { "type": "str", "default": "present", "choices": ["present", "absent"], }, "force": {"type": "bool", "default": False}, "path": {"type": "path", "required": True}, "privatekey_path": {"type": "path"}, "privatekey_content": {"type": "str", "no_log": True}, "format": {"type": "str", "default": "PEM", "choices": ["OpenSSH", "PEM"]}, "privatekey_passphrase": {"type": "str", "no_log": True}, "backup": {"type": "bool", "default": False}, "select_crypto_backend": { "type": "str", "choices": ["auto", "cryptography"], "default": "auto", }, "return_content": {"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"],), ) assert_required_cryptography_version( module, minimum_cryptography_version=MINIMAL_CRYPTOGRAPHY_VERSION ) base_dir = os.path.dirname(module.params["path"]) or "." if not os.path.isdir(base_dir): module.fail_json( name=base_dir, msg=f"The directory '{base_dir}' does not exist or the file is not a directory", ) try: public_key = PublicKey(module) 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=str(exc)) if __name__ == "__main__": main()