#!/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. 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.
requirements:
  - cryptography >= 1.2.3 (older versions might work as well)
  - Needs cryptography >= 1.4 if O(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
  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.
    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 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.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.io import (
    load_file_if_exists,
    write_file,
)
from ansible_collections.community.crypto.plugins.module_utils.version import (
    LooseVersion,
)


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:
            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:
                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()
