r"""
A salt module for SSL/TLS.  Can create a Certificate Authority (CA)
or use Self-Signed certificates.

:depends: PyOpenSSL Python module (0.10 or later, 0.14 or later for X509
    extension support)

:configuration: Add the following values in /etc/salt/minion for the CA module
    to function properly:

    .. code-block:: yaml

        ca.cert_base_path: '/etc/pki'


CLI Example #1:
Creating a CA, a server request and its signed certificate:

.. code-block:: bash

    # salt-call tls.create_ca my_little \
    days=5 \
    CN='My Little CA' \
    C=US \
    ST=Utah \
    L=Salt Lake City \
    O=Saltstack \
    emailAddress=pleasedontemail@example.com

    Created Private Key: "/etc/pki/my_little/my_little_ca_cert.key"
    Created CA "my_little_ca": "/etc/pki/my_little_ca/my_little_ca_cert.crt"

    # salt-call tls.create_csr my_little CN=www.example.com
    Created Private Key: "/etc/pki/my_little/certs/www.example.com.key
    Created CSR for "www.example.com": "/etc/pki/my_little/certs/www.example.com.csr"

    # salt-call tls.create_ca_signed_cert my_little CN=www.example.com
    Created Certificate for "www.example.com": /etc/pki/my_little/certs/www.example.com.crt"

CLI Example #2:
Creating a client request and its signed certificate

.. code-block:: bash

    # salt-call tls.create_csr my_little CN=DBReplica_No.1 cert_type=client
    Created Private Key: "/etc/pki/my_little/certs//DBReplica_No.1.key"
    Created CSR for "DBReplica_No.1": "/etc/pki/my_little/certs/DBReplica_No.1.csr"

    # salt-call tls.create_ca_signed_cert my_little CN=DBReplica_No.1
    Created Certificate for "DBReplica_No.1": "/etc/pki/my_little/certs/DBReplica_No.1.crt"

CLI Example #3:
Creating both a server and client req + cert for the same CN

.. code-block:: bash

    # salt-call tls.create_csr my_little CN=MasterDBReplica_No.2  \
        cert_type=client
    Created Private Key: "/etc/pki/my_little/certs/MasterDBReplica_No.2.key"
    Created CSR for "DBReplica_No.1": "/etc/pki/my_little/certs/MasterDBReplica_No.2.csr"

    # salt-call tls.create_ca_signed_cert my_little CN=MasterDBReplica_No.2
    Created Certificate for "DBReplica_No.1": "/etc/pki/my_little/certs/DBReplica_No.1.crt"

    # salt-call tls.create_csr my_little CN=MasterDBReplica_No.2 \
        cert_type=server
    Certificate "MasterDBReplica_No.2" already exists

    (doh!)

    # salt-call tls.create_csr my_little CN=MasterDBReplica_No.2 \
        cert_type=server type_ext=True
    Created Private Key: "/etc/pki/my_little/certs/DBReplica_No.1_client.key"
    Created CSR for "DBReplica_No.1": "/etc/pki/my_little/certs/DBReplica_No.1_client.csr"

    # salt-call tls.create_ca_signed_cert my_little CN=MasterDBReplica_No.2
    Certificate "MasterDBReplica_No.2" already exists

    (DOH!)

    # salt-call tls.create_ca_signed_cert my_little CN=MasterDBReplica_No.2 \
        cert_type=server type_ext=True
    Created Certificate for "MasterDBReplica_No.2": "/etc/pki/my_little/certs/MasterDBReplica_No.2_server.crt"


CLI Example #4:
Create a server req + cert with non-CN filename for the cert

.. code-block:: bash

    # salt-call tls.create_csr my_little CN=www.anothersometh.ing \
        cert_type=server type_ext=True
    Created Private Key: "/etc/pki/my_little/certs/www.anothersometh.ing_server.key"
    Created CSR for "DBReplica_No.1": "/etc/pki/my_little/certs/www.anothersometh.ing_server.csr"

    # salt-call tls_create_ca_signed_cert my_little CN=www.anothersometh.ing \
        cert_type=server cert_filename="something_completely_different"
    Created Certificate for "www.anothersometh.ing": /etc/pki/my_little/certs/something_completely_different.crt
"""

import binascii
import calendar
import logging
import math
import os
import re
import time
from datetime import datetime, timedelta, timezone

import salt.utils.data
import salt.utils.files
import salt.utils.stringutils
import salt.utils.timeutil
from salt.exceptions import CommandExecutionError
from salt.utils.versions import Version

# pylint: disable=C0103


HAS_SSL = False
X509_EXT_ENABLED = True
HAS_CRYPTOGRAPHY = False
HAS_X509_EXTENSION_API = False
try:
    import OpenSSL

    HAS_SSL = True
    OpenSSL_version = Version(OpenSSL.__dict__.get("__version__", "0.0"))
    # pyOpenSSL >= 26 removed the X509Extension class entirely; we cannot
    # construct v3 extensions through pyOpenSSL on those releases. The
    # surrounding helpers fall back to a warn-and-continue path in that
    # case.
    HAS_X509_EXTENSION = hasattr(OpenSSL.crypto, "X509Extension")
    # pyOpenSSL >= 26 also removed OpenSSL.crypto.CRL and load_crl.
    HAS_OPENSSL_CRL = hasattr(OpenSSL.crypto, "load_crl")
except ImportError:
    HAS_X509_EXTENSION = False
    HAS_OPENSSL_CRL = False

try:
    # pylint: disable=unused-import
    from OpenSSL.crypto import X509Extension  # noqa: F401

    # pylint: enable=unused-import

    HAS_X509_EXTENSION_API = True
except ImportError:
    # pyOpenSSL >= 25 removed X509Extension and the X509.add_extensions /
    # X509Req.add_extensions methods this module relies on. Use the
    # ``x509_v2`` modules (cryptography-based) for new deployments.
    pass

try:
    from cryptography import x509
    from cryptography.hazmat.primitives import hashes, serialization
    from cryptography.hazmat.primitives.serialization import pkcs12

    HAS_CRYPTOGRAPHY = True
except ImportError:
    pass


log = logging.getLogger(__name__)

two_digit_year_fmt = "%y%m%d%H%M%SZ"
four_digit_year_fmt = "%Y%m%d%H%M%SZ"


def __virtual__():
    """
    Only load this module if the ca config options are set
    """
    global X509_EXT_ENABLED
    if not HAS_SSL or OpenSSL_version < Version("0.10"):
        X509_EXT_ENABLED = False
        return (
            False,
            "PyOpenSSL version 0.10 or later must be installed "
            "before this module can be used.",
        )
    if not HAS_X509_EXTENSION_API:
        X509_EXT_ENABLED = False
        return (
            False,
            "The tls module requires the X509Extension API removed in "
            "pyOpenSSL 25. Use the x509_v2 modules instead.",
        )
    if OpenSSL_version < Version("0.14"):
        X509_EXT_ENABLED = False
        log.debug(
            "You should upgrade pyOpenSSL to at least 0.14.1 to "
            "enable the use of X509 extensions in the tls module"
        )
    elif OpenSSL_version <= Version("0.15"):
        log.debug(
            "You should upgrade pyOpenSSL to at least 0.15.1 to "
            "enable the full use of X509 extensions in the tls module"
        )
    # NOTE: Not having configured a cert path should not prevent this
    # module from loading as it provides methods to configure the path.
    return True


def _microtime():
    """
    Return a Unix timestamp as a string of digits
    :return:
    """
    val1, val2 = math.modf(time.time())
    val2 = int(val2)
    return f"{val1:f}{val2}"


def _context_or_config(key):
    """
    Return the value corresponding to the key in __context__ or if not present,
    fallback to config.option.
    """
    return __context__.get(key, __salt__["config.option"](key))


def cert_base_path(cacert_path=None):
    """
    Return the base path for certs from CLI or from options

    cacert_path
        absolute path to ca certificates root directory

    CLI Example:

    .. code-block:: bash

        salt '*' tls.cert_base_path
    """
    return (
        cacert_path
        or _context_or_config("ca.contextual_cert_base_path")
        or _context_or_config("ca.cert_base_path")
    )


def _cert_base_path(cacert_path=None):
    """
    Retrocompatible wrapper
    """
    return cert_base_path(cacert_path)


def set_ca_path(cacert_path):
    """
    If wanted, store the aforementioned cacert_path in context
    to be used as the basepath for further operations

    CLI Example:

    .. code-block:: bash

        salt '*' tls.set_ca_path /etc/certs
    """
    if cacert_path:
        __context__["ca.contextual_cert_base_path"] = cacert_path
    return cert_base_path()


def _new_serial(ca_name):
    """
    Return a serial number in hex using os.urandom() and a Unix timestamp
    in microseconds.

    ca_name
        name of the CA
    CN
        common name in the request
    """
    hashnum = int(
        binascii.hexlify(
            b"_".join(
                (
                    salt.utils.stringutils.to_bytes(_microtime()),
                    os.urandom(5),
                )
            )
        ),
        16,
    )
    log.debug("Hashnum: %s", hashnum)

    # record the hash somewhere
    cachedir = __opts__["cachedir"]
    log.debug("cachedir: %s", cachedir)
    serial_file = f"{cachedir}/{ca_name}.serial"
    if not os.path.exists(cachedir):
        os.makedirs(cachedir)
    if not os.path.exists(serial_file):
        mode = "w"
    else:
        mode = "a+"
    with salt.utils.files.fopen(serial_file, mode) as ofile:
        ofile.write(str(hashnum))

    return hashnum


def _four_digit_year_to_two_digit(datetimeObj):
    return datetimeObj.strftime(two_digit_year_fmt)


def _get_basic_info(ca_name, cert, ca_dir=None):
    """
    Get basic info to write out to the index.txt
    """
    if ca_dir is None:
        ca_dir = f"{_cert_base_path()}/{ca_name}"

    index_file = f"{ca_dir}/index.txt"

    cert = _read_cert(cert)
    expire_date = _four_digit_year_to_two_digit(_get_expiration_date(cert))
    serial_number = format(cert.get_serial_number(), "X")

    # gotta prepend a /
    subject = "/"

    # then we can add the rest of the subject
    subject += "/".join([f"{x}={y}" for x, y in cert.get_subject().get_components()])
    subject += "\n"

    return (index_file, expire_date, serial_number, subject)


def _write_cert_to_database(ca_name, cert, cacert_path=None, status="V"):
    """
    write out the index.txt database file in the appropriate directory to
    track certificates

    ca_name
        name of the CA
    cert
        certificate to be recorded
    """
    set_ca_path(cacert_path)
    ca_dir = f"{cert_base_path()}/{ca_name}"
    index_file, expire_date, serial_number, subject = _get_basic_info(
        ca_name, cert, ca_dir
    )

    index_data = "{}\t{}\t\t{}\tunknown\t{}".format(
        status, expire_date, serial_number, subject
    )

    with salt.utils.files.fopen(index_file, "a+") as ofile:
        ofile.write(salt.utils.stringutils.to_str(index_data))


def maybe_fix_ssl_version(ca_name, cacert_path=None, ca_filename=None):
    """
    Check that the X509 version is correct
    (was incorrectly set in previous salt versions).
    This will fix the version if needed.

    ca_name
        ca authority name
    cacert_path
        absolute path to ca certificates root directory
    ca_filename
        alternative filename for the CA

        .. versionadded:: 2015.5.3

    CLI Example:

    .. code-block:: bash

        salt '*' tls.maybe_fix_ssl_version test_ca /etc/certs
    """
    set_ca_path(cacert_path)
    if not ca_filename:
        ca_filename = f"{ca_name}_ca_cert"
    certp = f"{cert_base_path()}/{ca_name}/{ca_filename}.crt"
    ca_keyp = f"{cert_base_path()}/{ca_name}/{ca_filename}.key"
    with salt.utils.files.fopen(certp) as fic:
        cert = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, fic.read())
        if cert.get_version() == 3:
            log.info("Regenerating wrong x509 version for certificate %s", certp)
            with salt.utils.files.fopen(ca_keyp) as fic2:
                try:
                    # try to determine the key bits
                    key = OpenSSL.crypto.load_privatekey(
                        OpenSSL.crypto.FILETYPE_PEM, fic2.read()
                    )
                    bits = key.bits()
                except Exception:  # pylint: disable=broad-except
                    bits = 2048
                try:
                    days = (
                        datetime.strptime(cert.get_notAfter(), "%Y%m%d%H%M%SZ")
                        - salt.utils.timeutil.utcnow()
                    ).days
                except (ValueError, TypeError):
                    days = 365
                subj = cert.get_subject()
                create_ca(
                    ca_name,
                    bits=bits,
                    days=days,
                    CN=subj.CN,
                    C=subj.C,
                    ST=subj.ST,
                    L=subj.L,
                    O=subj.O,
                    OU=subj.OU,
                    emailAddress=subj.emailAddress,
                    fixmode=True,
                )


def ca_exists(ca_name, cacert_path=None, ca_filename=None):
    """
    Verify whether a Certificate Authority (CA) already exists

    ca_name
        name of the CA
    cacert_path
        absolute path to ca certificates root directory
    ca_filename
        alternative filename for the CA

        .. versionadded:: 2015.5.3

    CLI Example:

    .. code-block:: bash

        salt '*' tls.ca_exists test_ca /etc/certs
    """
    set_ca_path(cacert_path)
    if not ca_filename:
        ca_filename = f"{ca_name}_ca_cert"
    certp = f"{cert_base_path()}/{ca_name}/{ca_filename}.crt"
    if os.path.exists(certp):
        maybe_fix_ssl_version(ca_name, cacert_path=cacert_path, ca_filename=ca_filename)
        return True
    return False


def _ca_exists(ca_name, cacert_path=None):
    """Retrocompatible wrapper"""
    return ca_exists(ca_name, cacert_path)


def get_ca(ca_name, as_text=False, cacert_path=None):
    """
    Get the certificate path or content

    ca_name
        name of the CA
    as_text
        if true, return the certificate content instead of the path
    cacert_path
        absolute path to ca certificates root directory

    CLI Example:

    .. code-block:: bash

        salt '*' tls.get_ca test_ca as_text=False cacert_path=/etc/certs
    """
    set_ca_path(cacert_path)
    certp = "{0}/{1}/{1}_ca_cert.crt".format(cert_base_path(), ca_name)
    if not os.path.exists(certp):
        raise ValueError(f"Certificate does not exist for {ca_name}")
    else:
        if as_text:
            with salt.utils.files.fopen(certp) as fic:
                certp = salt.utils.stringutils.to_unicode(fic.read())
    return certp


def get_ca_signed_cert(
    ca_name, CN="localhost", as_text=False, cacert_path=None, cert_filename=None
):
    """
    Get the certificate path or content

    ca_name
        name of the CA
    CN
        common name of the certificate
    as_text
        if true, return the certificate content instead of the path
    cacert_path
        absolute path to certificates root directory
    cert_filename
        alternative filename for the certificate, useful when using special characters in the CN

        .. versionadded:: 2015.5.3

    CLI Example:

    .. code-block:: bash

        salt '*' tls.get_ca_signed_cert test_ca CN=localhost as_text=False cacert_path=/etc/certs
    """
    set_ca_path(cacert_path)
    if not cert_filename:
        cert_filename = CN

    certp = f"{cert_base_path()}/{ca_name}/certs/{cert_filename}.crt"
    if not os.path.exists(certp):
        raise ValueError(f"Certificate does not exists for {CN}")
    else:
        if as_text:
            with salt.utils.files.fopen(certp) as fic:
                certp = salt.utils.stringutils.to_unicode(fic.read())
    return certp


def get_ca_signed_key(
    ca_name, CN="localhost", as_text=False, cacert_path=None, key_filename=None
):
    """
    Get the certificate path or content

    ca_name
        name of the CA
    CN
        common name of the certificate
    as_text
        if true, return the certificate content instead of the path
    cacert_path
        absolute path to certificates root directory
    key_filename
        alternative filename for the key, useful when using special characters

        .. versionadded:: 2015.5.3

        in the CN

    CLI Example:

    .. code-block:: bash

        salt '*' tls.get_ca_signed_key \
                test_ca CN=localhost \
                as_text=False \
                cacert_path=/etc/certs
    """
    set_ca_path(cacert_path)
    if not key_filename:
        key_filename = CN

    keyp = f"{cert_base_path()}/{ca_name}/certs/{key_filename}.key"
    if not os.path.exists(keyp):
        raise ValueError(f"Certificate does not exists for {CN}")
    else:
        if as_text:
            with salt.utils.files.fopen(keyp) as fic:
                keyp = salt.utils.stringutils.to_unicode(fic.read())
    return keyp


def _read_cert(cert):
    if isinstance(cert, str):
        try:
            with salt.utils.files.fopen(cert) as rfh:
                return OpenSSL.crypto.load_certificate(
                    OpenSSL.crypto.FILETYPE_PEM, rfh.read()
                )
        except Exception:  # pylint: disable=broad-except
            log.exception("Failed to read cert from path %s", cert)
            return None
    else:
        if not hasattr(cert, "get_notAfter"):
            log.error("%s is not a valid cert path/object", cert)
            return None
        else:
            return cert


def validate(cert, ca_name, crl_file):
    """
    .. versionadded:: 3000

    Validate a certificate against a given CA/CRL.

    cert
        path to the certifiate PEM file or string

    ca_name
        name of the CA

    crl_file
        full path to the CRL file
    """
    store = OpenSSL.crypto.X509Store()
    cert_obj = _read_cert(cert)
    if cert_obj is None:
        raise CommandExecutionError(
            f"Failed to read cert from {cert}, see log for details"
        )
    ca_dir = f"{cert_base_path()}/{ca_name}"
    ca_cert = _read_cert(f"{ca_dir}/{ca_name}_ca_cert.crt")
    store.add_cert(ca_cert)
    # These flags tell OpenSSL to check the leaf as well as the
    # entire cert chain.
    X509StoreFlags = OpenSSL.crypto.X509StoreFlags
    store.set_flags(X509StoreFlags.CRL_CHECK | X509StoreFlags.CRL_CHECK_ALL)
    if crl_file is None:
        if HAS_CRYPTOGRAPHY:
            ca_keyp = f"{ca_dir}/{ca_name}_ca_cert.key"
            try:
                with salt.utils.files.fopen(ca_keyp, "rb") as fhr:
                    ca_key = serialization.load_pem_private_key(
                        fhr.read(), password=None
                    )

                ca_x509 = x509.load_pem_x509_certificate(
                    OpenSSL.crypto.dump_certificate(
                        OpenSSL.crypto.FILETYPE_PEM, ca_cert
                    )
                )

                builder = x509.CertificateRevocationListBuilder()
                builder = builder.issuer_name(ca_x509.subject)
                builder = builder.last_update(salt.utils.timeutil.utcnow())
                builder = builder.next_update(
                    salt.utils.timeutil.utcnow() + timedelta(days=36500)
                )

                # Load existing revocations from index file if it exists
                index_file = f"{ca_dir}/index.txt"
                if os.path.exists(index_file):
                    with salt.utils.files.fopen(index_file) as fp_:
                        for line in fp_:
                            line = salt.utils.stringutils.to_unicode(line)
                            if line.startswith("R"):
                                fields = line.split("\t")
                                serial = int(fields[3], 16)
                                if serial.bit_length() >= 160:
                                    serial = serial & ((1 << 159) - 1)
                                revocation_date = datetime.strptime(
                                    fields[2], two_digit_year_fmt
                                )
                                revoked_builder = x509.RevokedCertificateBuilder()
                                revoked_builder = revoked_builder.serial_number(serial)
                                revoked_builder = revoked_builder.revocation_date(
                                    revocation_date
                                )
                                builder = builder.add_revoked_certificate(
                                    revoked_builder.build()
                                )

                # Sign the CRL
                crl = builder.sign(private_key=ca_key, algorithm=hashes.SHA256())
            except (OSError, ValueError) as e:
                log.warning("Could not create empty CRL: %s", e)
                return {
                    "valid": False,
                    "error": "Empty CRL requested but CA key missing or invalid",
                }
        else:
            if HAS_OPENSSL_CRL:
                crl = OpenSSL.crypto.CRL()
            else:
                log.error(
                    "Cannot create empty CRL: pyOpenSSL >= 26 removed "
                    "OpenSSL.crypto.CRL and the cryptography library is not available."
                )
                return {
                    "valid": False,
                    "error": "CRL creation not supported on this pyOpenSSL version",
                }
    else:
        if HAS_CRYPTOGRAPHY:
            with salt.utils.files.fopen(crl_file, "rb") as fhr:
                crl = x509.load_pem_x509_crl(fhr.read())
        elif HAS_OPENSSL_CRL:
            with salt.utils.files.fopen(crl_file) as fhr:
                crl = OpenSSL.crypto.load_crl(OpenSSL.crypto.FILETYPE_PEM, fhr.read())
        else:
            log.error(
                "Cannot load CRL file: pyOpenSSL >= 26 removed load_crl and "
                "the cryptography library is not available."
            )
            return {"valid": False, "error": "CRL loading not supported"}
    store.add_crl(crl)

    if HAS_CRYPTOGRAPHY:
        cert_x509 = x509.load_pem_x509_certificate(
            OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_PEM, cert_obj)
        )
        serial_to_check = cert_x509.serial_number
        if serial_to_check.bit_length() >= 160:
            serial_to_check = serial_to_check & ((1 << 159) - 1)

        if crl.get_revoked_certificate_by_serial_number(serial_to_check):
            return {
                "valid": False,
                "error": "certificate revoked",
                "error_cert": cert_obj,
            }

    context = OpenSSL.crypto.X509StoreContext(store, cert_obj)
    ret = {}
    try:
        context.verify_certificate()
        ret["valid"] = True
    except OpenSSL.crypto.X509StoreContextError as e:
        ret["error"] = str(e)
        ret["error_cert"] = e.certificate
        ret["valid"] = False
    return ret


def _get_expiration_date(cert):
    """
    Returns a datetime.datetime object
    """
    cert_obj = _read_cert(cert)

    if cert_obj is None:
        raise CommandExecutionError(
            f"Failed to read cert from {cert}, see log for details"
        )

    return datetime.strptime(
        salt.utils.stringutils.to_str(cert_obj.get_notAfter()), four_digit_year_fmt
    )


def get_expiration_date(cert, date_format="%Y-%m-%d"):
    """
    .. versionadded:: 2019.2.0

    Get a certificate's expiration date

    cert
        Full path to the certificate

    date_format
        By default this will return the expiration date in YYYY-MM-DD format,
        use this to specify a different strftime format string. Note that the
        expiration time will be in UTC.

    CLI Examples:

    .. code-block:: bash

        salt '*' tls.get_expiration_date /path/to/foo.crt
        salt '*' tls.get_expiration_date /path/to/foo.crt date_format='%d/%m/%Y'
    """
    return _get_expiration_date(cert).strftime(date_format)


def _check_onlyif_unless(onlyif, unless):
    ret = None
    retcode = __salt__["cmd.retcode"]
    if onlyif is not None:
        if not isinstance(onlyif, str):
            if not onlyif:
                ret = {"comment": "onlyif condition is false", "result": True}
        elif isinstance(onlyif, str):
            if retcode(onlyif) != 0:
                ret = {"comment": "onlyif condition is false", "result": True}
                log.debug("onlyif condition is false")
    if unless is not None:
        if not isinstance(unless, str):
            if unless:
                ret = {"comment": "unless condition is true", "result": True}
        elif isinstance(unless, str):
            if retcode(unless) == 0:
                ret = {"comment": "unless condition is true", "result": True}
                log.debug("unless condition is true")
    return ret


def create_ca(
    ca_name,
    bits=2048,
    days=365,
    CN="localhost",
    C="US",
    ST="Utah",
    L="Salt Lake City",
    O="SaltStack",
    OU=None,
    emailAddress=None,
    fixmode=False,
    cacert_path=None,
    ca_filename=None,
    digest="sha256",
    onlyif=None,
    unless=None,
    replace=False,
):
    """
    Create a Certificate Authority (CA)

    ca_name
        name of the CA
    bits
        number of RSA key bits, default is 2048
    days
        number of days the CA will be valid, default is 365
    CN
        common name in the request, default is "localhost"
    C
        country, default is "US"
    ST
        state, default is "Utah"
    L
        locality, default is "Centerville", the city where SaltStack originated
    O
        organization, default is "SaltStack"
    OU
        organizational unit, default is None
    emailAddress
        email address for the CA owner, default is None
    cacert_path
        absolute path to ca certificates root directory
    ca_filename
        alternative filename for the CA

        .. versionadded:: 2015.5.3

    digest
        The message digest algorithm. Must be a string describing a digest
        algorithm supported by OpenSSL (by EVP_get_digestbyname, specifically).
        For example, "md5" or "sha1". Default: 'sha256'
    replace
        Replace this certificate even if it exists

        .. versionadded:: 2015.5.1

    Writes out a CA certificate based upon defined config values. If the file
    already exists, the function just returns assuming the CA certificate
    already exists.

    If the following values were set::

        ca.cert_base_path='/etc/pki'
        ca_name='koji'

    the resulting CA, and corresponding key, would be written in the following
    location with appropriate permissions::

        /etc/pki/koji/koji_ca_cert.crt
        /etc/pki/koji/koji_ca_cert.key

    CLI Example:

    .. code-block:: bash

        salt '*' tls.create_ca test_ca
    """
    status = _check_onlyif_unless(onlyif, unless)
    if status is not None:
        return None

    set_ca_path(cacert_path)

    if not ca_filename:
        ca_filename = f"{ca_name}_ca_cert"

    certp = f"{cert_base_path()}/{ca_name}/{ca_filename}.crt"
    ca_keyp = f"{cert_base_path()}/{ca_name}/{ca_filename}.key"
    if not replace and not fixmode and ca_exists(ca_name, ca_filename=ca_filename):
        return f'Certificate for CA named "{ca_name}" already exists'

    if fixmode and not os.path.exists(certp):
        raise ValueError(f"{certp} does not exists, can't fix")

    if not os.path.exists(f"{cert_base_path()}/{ca_name}"):
        os.makedirs(f"{cert_base_path()}/{ca_name}")

    # try to reuse existing ssl key
    key = None
    if os.path.exists(ca_keyp):
        with salt.utils.files.fopen(ca_keyp) as fic2:
            # try to determine the key bits
            try:
                key = OpenSSL.crypto.load_privatekey(
                    OpenSSL.crypto.FILETYPE_PEM, fic2.read()
                )
            except OpenSSL.crypto.Error as err:
                log.warning(
                    "Error loading existing private key %s, generating a new key: %s",
                    ca_keyp,
                    err,
                )
                bck = "{}.unloadable.{}".format(
                    ca_keyp, salt.utils.timeutil.utcnow().strftime("%Y%m%d%H%M%S")
                )
                log.info("Saving unloadable CA ssl key in %s", bck)
                os.rename(ca_keyp, bck)

    if not key:
        key = OpenSSL.crypto.PKey()
        key.generate_key(OpenSSL.crypto.TYPE_RSA, bits)

    ca = OpenSSL.crypto.X509()
    ca.set_version(2)
    ca.set_serial_number(_new_serial(ca_name))
    ca.get_subject().C = C
    ca.get_subject().ST = ST
    ca.get_subject().L = L
    ca.get_subject().O = O
    if OU:
        ca.get_subject().OU = OU
    ca.get_subject().CN = CN
    if emailAddress:
        ca.get_subject().emailAddress = emailAddress

    ca.gmtime_adj_notBefore(0)
    ca.gmtime_adj_notAfter(int(days) * 24 * 60 * 60)
    ca.set_issuer(ca.get_subject())
    ca.set_pubkey(key)

    if X509_EXT_ENABLED and HAS_X509_EXTENSION and hasattr(ca, "add_extensions"):
        # pyOpenSSL >= 26 removed X509.add_extensions / X509Extension; the
        # tls module's CA-management path is legacy and has not been ported
        # to the cryptography x509 API yet. Skip the extension setup when
        # running on a pyOpenSSL release that no longer ships these methods,
        # so the basic CA creation still succeeds. Users that need v3
        # extensions on pyOpenSSL >= 26 should migrate to the x509_v2 state.
        ca.add_extensions(
            [
                OpenSSL.crypto.X509Extension(
                    b"basicConstraints", True, b"CA:TRUE, pathlen:0"
                ),
                OpenSSL.crypto.X509Extension(
                    b"keyUsage", True, b"keyCertSign, cRLSign"
                ),
                OpenSSL.crypto.X509Extension(
                    b"subjectKeyIdentifier", False, b"hash", subject=ca
                ),
            ]
        )

        ca.add_extensions(
            [
                OpenSSL.crypto.X509Extension(
                    b"authorityKeyIdentifier",
                    False,
                    b"issuer:always,keyid:always",
                    issuer=ca,
                )
            ]
        )
    ca.sign(key, salt.utils.stringutils.to_str(digest))

    if X509_EXT_ENABLED and not HAS_X509_EXTENSION and HAS_CRYPTOGRAPHY:
        # pyOpenSSL 26 removed X509Extension; rebuild the CA cert using
        # cryptography to add basicConstraints: CA:TRUE so that
        # X509StoreContext recognises the cert as a valid CA.
        ca_pem = OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_PEM, ca)
        key_pem = OpenSSL.crypto.dump_privatekey(OpenSSL.crypto.FILETYPE_PEM, key)
        old_ca = x509.load_pem_x509_certificate(ca_pem)
        crypto_key = serialization.load_pem_private_key(key_pem, password=None)

        def _asn1_to_utc(asn1_bytes):
            s = asn1_bytes.decode("ascii").rstrip("Z")
            return datetime.strptime(s, "%Y%m%d%H%M%S").replace(tzinfo=timezone.utc)

        # cryptography limits serial numbers to 159 bits (RFC 5280); _new_serial
        # can produce larger values, so truncate to fit.
        ca_serial = old_ca.serial_number
        if ca_serial.bit_length() >= 160:
            ca_serial = ca_serial & ((1 << 159) - 1)

        new_ca_cert = (
            x509.CertificateBuilder()
            .subject_name(old_ca.subject)
            .issuer_name(old_ca.issuer)
            .public_key(old_ca.public_key())
            .serial_number(ca_serial)
            .not_valid_before(_asn1_to_utc(ca.get_notBefore()))
            .not_valid_after(_asn1_to_utc(ca.get_notAfter()))
            .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True)
            .add_extension(
                x509.KeyUsage(
                    digital_signature=False,
                    content_commitment=False,
                    key_encipherment=False,
                    data_encipherment=False,
                    key_agreement=False,
                    key_cert_sign=True,
                    crl_sign=True,
                    encipher_only=False,
                    decipher_only=False,
                ),
                critical=True,
            )
            .sign(crypto_key, hashes.SHA256())
        )
        ca = OpenSSL.crypto.load_certificate(
            OpenSSL.crypto.FILETYPE_PEM,
            new_ca_cert.public_bytes(serialization.Encoding.PEM),
        )
    elif X509_EXT_ENABLED and not HAS_X509_EXTENSION:
        log.warning(
            "pyOpenSSL %s no longer exposes X509.add_extensions and the "
            "cryptography library is not available; the tls module is "
            "creating CA %s without v3 extensions. Use the x509_v2 state "
            "module if you need them.",
            OpenSSL_version,
            ca_name,
        )

    # always backup existing keys in case
    keycontent = OpenSSL.crypto.dump_privatekey(OpenSSL.crypto.FILETYPE_PEM, key)
    write_key = True
    if os.path.exists(ca_keyp):
        bck = "{}.{}".format(
            ca_keyp, salt.utils.timeutil.utcnow().strftime("%Y%m%d%H%M%S")
        )
        with salt.utils.files.fopen(ca_keyp) as fic:
            old_key = salt.utils.stringutils.to_unicode(fic.read()).strip()
            if old_key.strip() == keycontent.strip():
                write_key = False
            else:
                log.info("Saving old CA ssl key in %s", bck)
                fp = os.open(bck, os.O_CREAT | os.O_RDWR, 0o600)
                with salt.utils.files.fopen(fp, "w") as bckf:
                    bckf.write(old_key)
    if write_key:
        fp = os.open(ca_keyp, os.O_CREAT | os.O_RDWR, 0o600)
        with salt.utils.files.fopen(fp, "wb") as ca_key:
            ca_key.write(salt.utils.stringutils.to_bytes(keycontent))

    fp = os.open(certp, os.O_CREAT | os.O_RDWR, 0o644)
    with salt.utils.files.fopen(fp, "wb") as ca_crt:
        ca_crt.write(
            salt.utils.stringutils.to_bytes(
                OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_PEM, ca)
            )
        )

    _write_cert_to_database(ca_name, ca)

    ret = 'Created Private Key: "{}/{}/{}.key" '.format(
        cert_base_path(), ca_name, ca_filename
    )
    ret += 'Created CA "{0}": "{1}/{0}/{2}.crt"'.format(
        ca_name, cert_base_path(), ca_filename
    )

    return ret


def get_extensions(cert_type):
    """
    Fetch X509 and CSR extension definitions from tls:extensions:
    (common|server|client) or set them to standard defaults.

    .. versionadded:: 2015.8.0

    cert_type:
        The type of certificate such as ``server`` or ``client``.

    CLI Example:

    .. code-block:: bash

        salt '*' tls.get_extensions client

    """

    assert X509_EXT_ENABLED, (
        "X509 extensions are not supported in "
        "pyOpenSSL prior to version 0.15.1. Your "
        "version: {}".format(OpenSSL_version)
    )

    ext = {}
    if cert_type == "":
        log.error(
            "cert_type set to empty in tls_ca.get_extensions(); "
            "defaulting to ``server``"
        )
        cert_type = "server"

    try:
        # unmask=True: extension values are written into CSRs/certificates,
        # so the real strings are needed, not the masked placeholders.
        ext["common"] = __salt__["pillar.get"](
            "tls.extensions:common", False, unmask=True
        )
    except NameError as err:
        log.debug(err)

    if not ext["common"] or ext["common"] == "":
        ext["common"] = {
            "csr": {"basicConstraints": "CA:FALSE"},
            "cert": {
                "authorityKeyIdentifier": "keyid,issuer:always",
                "subjectKeyIdentifier": "hash",
            },
        }

    try:
        ext["server"] = __salt__["pillar.get"](
            "tls.extensions:server", False, unmask=True
        )
    except NameError as err:
        log.debug(err)

    if not ext["server"] or ext["server"] == "":
        ext["server"] = {
            "csr": {
                "extendedKeyUsage": "serverAuth",
                "keyUsage": "digitalSignature, keyEncipherment",
            },
            "cert": {},
        }

    try:
        ext["client"] = __salt__["pillar.get"](
            "tls.extensions:client", False, unmask=True
        )
    except NameError as err:
        log.debug(err)

    if not ext["client"] or ext["client"] == "":
        ext["client"] = {
            "csr": {
                "extendedKeyUsage": "clientAuth",
                "keyUsage": "nonRepudiation, digitalSignature, keyEncipherment",
            },
            "cert": {},
        }

    # possible user-defined profile or a typo
    if cert_type not in ext:
        try:
            ext[cert_type] = __salt__["pillar.get"](
                f"tls.extensions:{cert_type}", unmask=True
            )
        except NameError as e:
            log.debug(
                "pillar, tls:extensions:%s not available or "
                "not operating in a salt context\n%s",
                cert_type,
                e,
            )

    retval = ext["common"]

    for Use in retval:
        retval[Use].update(ext[cert_type][Use])

    return retval


def create_csr(
    ca_name,
    bits=2048,
    CN="localhost",
    C="US",
    ST="Utah",
    L="Salt Lake City",
    O="SaltStack",
    OU=None,
    emailAddress=None,
    subjectAltName=None,
    cacert_path=None,
    ca_filename=None,
    csr_path=None,
    csr_filename=None,
    digest="sha256",
    type_ext=False,
    cert_type="server",
    replace=False,
):
    """
    Create a Certificate Signing Request (CSR) for a
    particular Certificate Authority (CA)

    ca_name
        name of the CA
    bits
        number of RSA key bits, default is 2048
    CN
        common name in the request, default is "localhost"
    C
        country, default is "US"
    ST
        state, default is "Utah"
    L
        locality, default is "Centerville", the city where SaltStack originated
    O
        organization, default is "SaltStack"
        NOTE: Must the same as CA certificate or an error will be raised
    OU
        organizational unit, default is None
    emailAddress
        email address for the request, default is None
    subjectAltName
        valid subjectAltNames in full form, e.g. to add DNS entry you would call
        this function with this value:

        examples: ['DNS:somednsname.com',
                'DNS:1.2.3.4',
                'IP:1.2.3.4',
                'IP:2001:4801:7821:77:be76:4eff:fe11:e51',
                'email:me@i.like.pie.com']

    .. note::
        some libraries do not properly query IP: prefixes, instead looking
        for the given req. source with a DNS: prefix. To be thorough, you
        may want to include both DNS: and IP: entries if you are using
        subjectAltNames for destinations for your TLS connections.
        e.g.:
        requests to https://1.2.3.4 will fail from python's
        requests library w/out the second entry in the above list

    .. versionadded:: 2015.8.0

    cert_type
        Specify the general certificate type. Can be either `server` or
        `client`. Indicates the set of common extensions added to the CSR.

        .. code-block:: cfg

            server: {
               'basicConstraints': 'CA:FALSE',
               'extendedKeyUsage': 'serverAuth',
               'keyUsage': 'digitalSignature, keyEncipherment'
            }

            client: {
               'basicConstraints': 'CA:FALSE',
               'extendedKeyUsage': 'clientAuth',
               'keyUsage': 'nonRepudiation, digitalSignature, keyEncipherment'
            }

    type_ext
        boolean.  Whether or not to extend the filename with CN_[cert_type]
        This can be useful if a server and client certificate are needed for
        the same CN. Defaults to False to avoid introducing an unexpected file
        naming pattern

        The files normally named some_subject_CN.csr and some_subject_CN.key
        will then be saved

    replace
        Replace this signing request even if it exists

        .. versionadded:: 2015.5.1

    Writes out a Certificate Signing Request (CSR) If the file already
    exists, the function just returns assuming the CSR already exists.

    If the following values were set::

        ca.cert_base_path='/etc/pki'
        ca_name='koji'
        CN='test.egavas.org'

    the resulting CSR, and corresponding key, would be written in the
    following location with appropriate permissions::

        /etc/pki/koji/certs/test.egavas.org.csr
        /etc/pki/koji/certs/test.egavas.org.key

    CLI Example:

    .. code-block:: bash

        salt '*' tls.create_csr test
    """
    set_ca_path(cacert_path)

    if not ca_filename:
        ca_filename = f"{ca_name}_ca_cert"

    if not ca_exists(ca_name, ca_filename=ca_filename):
        return 'Certificate for CA named "{}" does not exist, please create it first.'.format(
            ca_name
        )

    if not csr_path:
        csr_path = f"{cert_base_path()}/{ca_name}/certs/"

    if not os.path.exists(csr_path):
        os.makedirs(csr_path)

    CN_ext = f"_{cert_type}" if type_ext else ""

    if not csr_filename:
        csr_filename = f"{CN}{CN_ext}"

    csr_f = f"{csr_path}/{csr_filename}.csr"

    if not replace and os.path.exists(csr_f):
        return f'Certificate Request "{csr_f}" already exists'

    key = OpenSSL.crypto.PKey()
    key.generate_key(OpenSSL.crypto.TYPE_RSA, bits)

    req = OpenSSL.crypto.X509Req()

    req.get_subject().C = C
    req.get_subject().ST = ST
    req.get_subject().L = L
    req.get_subject().O = O
    if OU:
        req.get_subject().OU = OU
    req.get_subject().CN = CN
    if emailAddress:
        req.get_subject().emailAddress = emailAddress

    extension_adds = []
    try:
        extensions = get_extensions(cert_type)["csr"]

        if HAS_X509_EXTENSION:
            for ext, value in extensions.items():
                if isinstance(value, str):
                    value = salt.utils.stringutils.to_bytes(value)
                extension_adds.append(
                    OpenSSL.crypto.X509Extension(
                        salt.utils.stringutils.to_bytes(ext), False, value
                    )
                )
    except AssertionError as err:
        log.error(err)
        extensions = []

    if subjectAltName:
        if not X509_EXT_ENABLED:
            raise ValueError(
                "subjectAltName cannot be set as X509 "
                "extensions are not supported in pyOpenSSL "
                "prior to version 0.15.1. Your "
                "version: {}.".format(OpenSSL_version)
            )
        if HAS_X509_EXTENSION:
            if isinstance(subjectAltName, str):
                subjectAltName = [subjectAltName]

            extension_adds.append(
                OpenSSL.crypto.X509Extension(
                    b"subjectAltName",
                    False,
                    b", ".join(salt.utils.data.encode(subjectAltName)),
                )
            )
        else:
            log.warning(
                "pyOpenSSL %s no longer ships X509Extension; subjectAltName "
                "for %s will be ignored. Use the x509_v2 state module if you "
                "need SAN entries on a CSR.",
                OpenSSL_version,
                CN,
            )

    if X509_EXT_ENABLED and hasattr(req, "add_extensions"):
        # See create_ca() above for why we tolerate the missing-method case
        # on pyOpenSSL >= 26.
        req.add_extensions(extension_adds)
    elif X509_EXT_ENABLED and extension_adds:
        log.warning(
            "pyOpenSSL %s no longer exposes X509Req.add_extensions; CSR "
            "for %s will not carry the requested v3 extensions. Use the "
            "x509_v2 state module if you need them.",
            OpenSSL_version,
            CN,
        )

    req.set_pubkey(key)
    req.sign(key, salt.utils.stringutils.to_str(digest))

    # Write private key and request
    priv_keyp = f"{csr_path}/{csr_filename}.key"
    fp = os.open(priv_keyp, os.O_CREAT | os.O_RDWR, 0o600)
    with salt.utils.files.fopen(fp, "wb+") as priv_key:
        priv_key.write(
            salt.utils.stringutils.to_bytes(
                OpenSSL.crypto.dump_privatekey(OpenSSL.crypto.FILETYPE_PEM, key)
            )
        )

    fp = os.open(csr_f, os.O_CREAT | os.O_RDWR, 0o644)
    with salt.utils.files.fopen(fp, "wb+") as csr:
        csr.write(
            salt.utils.stringutils.to_bytes(
                OpenSSL.crypto.dump_certificate_request(
                    OpenSSL.crypto.FILETYPE_PEM, req
                )
            )
        )

    ret = f'Created Private Key: "{os.path.join(csr_path, csr_filename)}.key" '
    ret += f'Created CSR for "{CN}": "{os.path.join(csr_path, csr_filename)}.csr"'

    return ret


def create_self_signed_cert(
    tls_dir="tls",
    bits=2048,
    days=365,
    CN="localhost",
    C="US",
    ST="Utah",
    L="Salt Lake City",
    O="SaltStack",
    OU=None,
    emailAddress=None,
    cacert_path=None,
    cert_filename=None,
    digest="sha256",
    replace=False,
):
    """
    Create a Self-Signed Certificate (CERT)

    tls_dir
        location appended to the ca.cert_base_path, default is 'tls'
    bits
        number of RSA key bits, default is 2048
    CN
        common name in the request, default is "localhost"
    C
        country, default is "US"
    ST
        state, default is "Utah"
    L
        locality, default is "Centerville", the city where SaltStack originated
    O
        organization, default is "SaltStack"
        NOTE: Must the same as CA certificate or an error will be raised
    OU
        organizational unit, default is None
    emailAddress
        email address for the request, default is None
    cacert_path
        absolute path to ca certificates root directory
    digest
        The message digest algorithm. Must be a string describing a digest
        algorithm supported by OpenSSL (by EVP_get_digestbyname, specifically).
        For example, "md5" or "sha1". Default: 'sha256'
    replace
        Replace this certificate even if it exists

        .. versionadded:: 2015.5.1

    Writes out a Self-Signed Certificate (CERT). If the file already
    exists, the function just returns.

    If the following values were set::

        ca.cert_base_path='/etc/pki'
        tls_dir='koji'
        CN='test.egavas.org'

    the resulting CERT, and corresponding key, would be written in the
    following location with appropriate permissions::

        /etc/pki/koji/certs/test.egavas.org.crt
        /etc/pki/koji/certs/test.egavas.org.key

    CLI Example:

    .. code-block:: bash

        salt '*' tls.create_self_signed_cert

    Passing options from the command line:

    .. code-block:: bash

        salt 'minion' tls.create_self_signed_cert CN='test.mysite.org'
    """
    set_ca_path(cacert_path)

    if not os.path.exists(f"{cert_base_path()}/{tls_dir}/certs/"):
        os.makedirs(f"{cert_base_path()}/{tls_dir}/certs/")

    if not cert_filename:
        cert_filename = CN

    if not replace and os.path.exists(
        f"{cert_base_path()}/{tls_dir}/certs/{cert_filename}.crt"
    ):
        return f'Certificate "{cert_filename}" already exists'

    key = OpenSSL.crypto.PKey()
    key.generate_key(OpenSSL.crypto.TYPE_RSA, bits)

    # create certificate
    cert = OpenSSL.crypto.X509()
    cert.set_version(2)

    cert.gmtime_adj_notBefore(0)
    cert.gmtime_adj_notAfter(int(days) * 24 * 60 * 60)

    cert.get_subject().C = C
    cert.get_subject().ST = ST
    cert.get_subject().L = L
    cert.get_subject().O = O
    if OU:
        cert.get_subject().OU = OU
    cert.get_subject().CN = CN
    if emailAddress:
        cert.get_subject().emailAddress = emailAddress

    cert.set_serial_number(_new_serial(tls_dir))
    cert.set_issuer(cert.get_subject())
    cert.set_pubkey(key)
    cert.sign(key, salt.utils.stringutils.to_str(digest))

    # Write private key and cert
    priv_key_path = "{}/{}/certs/{}.key".format(
        cert_base_path(), tls_dir, cert_filename
    )
    fp = os.open(priv_key_path, os.O_CREAT | os.O_RDWR, 0o600)
    with salt.utils.files.fopen(fp, "wb+") as priv_key:
        priv_key.write(
            salt.utils.stringutils.to_bytes(
                OpenSSL.crypto.dump_privatekey(OpenSSL.crypto.FILETYPE_PEM, key)
            )
        )

    crt_path = f"{cert_base_path()}/{tls_dir}/certs/{cert_filename}.crt"
    fp = os.open(crt_path, os.O_CREAT | os.O_RDWR, 0o644)
    with salt.utils.files.fopen(fp, "wb+") as crt:
        crt.write(
            salt.utils.stringutils.to_bytes(
                OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_PEM, cert)
            )
        )

    _write_cert_to_database(tls_dir, cert)

    ret = 'Created Private Key: "{}/{}/certs/{}.key" '.format(
        cert_base_path(), tls_dir, cert_filename
    )
    ret += 'Created Certificate: "{}/{}/certs/{}.crt"'.format(
        cert_base_path(), tls_dir, cert_filename
    )

    return ret


def create_ca_signed_cert(
    ca_name,
    CN,
    days=365,
    cacert_path=None,
    ca_filename=None,
    cert_path=None,
    cert_filename=None,
    digest="sha256",
    cert_type=None,
    type_ext=False,
    replace=False,
):
    """
    Create a Certificate (CERT) signed by a named Certificate Authority (CA)

    If the certificate file already exists, the function just returns assuming
    the CERT already exists.

    The CN *must* match an existing CSR generated by create_csr. If it
    does not, this method does nothing.

    ca_name
        name of the CA
    CN
        common name matching the certificate signing request
    days
        number of days certificate is valid, default is 365 (1 year)

    cacert_path
        absolute path to ca certificates root directory

    ca_filename
        alternative filename for the CA

        .. versionadded:: 2015.5.3


    cert_path
        full path to the certificates directory

    cert_filename
        alternative filename for the certificate, useful when using special
        characters in the CN. If this option is set it will override
        the certificate filename output effects of ``cert_type``.
        ``type_ext`` will be completely overridden.

        .. versionadded:: 2015.5.3


    digest
        The message digest algorithm. Must be a string describing a digest
        algorithm supported by OpenSSL (by EVP_get_digestbyname, specifically).
        For example, "md5" or "sha1". Default: 'sha256'
    replace
        Replace this certificate even if it exists

        .. versionadded:: 2015.5.1

    cert_type
        string. Either 'server' or 'client' (see create_csr() for details).

        If create_csr(type_ext=True) this function **must** be called with the
        same cert_type so it can find the CSR file.

    .. note::
        create_csr() defaults to cert_type='server'; therefore, if it was also
        called with type_ext, cert_type becomes a required argument for
        create_ca_signed_cert()

    type_ext
        bool. If set True, use ``cert_type`` as an extension to the CN when
        formatting the filename.

        e.g.: some_subject_CN_server.crt or some_subject_CN_client.crt

        This facilitates the context where both types are required for the same
        subject

        If ``cert_filename`` is `not None`, setting ``type_ext`` has no
        effect

    If the following values were set:

    .. code-block:: text

        ca.cert_base_path='/etc/pki'
        ca_name='koji'
        CN='test.egavas.org'

    the resulting signed certificate would be written in the following
    location:

    .. code-block:: text

        /etc/pki/koji/certs/test.egavas.org.crt

    CLI Example:

    .. code-block:: bash

        salt '*' tls.create_ca_signed_cert test localhost
    """
    ret = {}

    set_ca_path(cacert_path)

    if not ca_filename:
        ca_filename = f"{ca_name}_ca_cert"

    if not cert_path:
        cert_path = f"{cert_base_path()}/{ca_name}/certs"

    if type_ext:
        if not cert_type:
            log.error(
                "type_ext = True but cert_type is unset. Certificate not written."
            )
            return ret
        elif cert_type:
            CN_ext = f"_{cert_type}"
    else:
        CN_ext = ""

    csr_filename = f"{CN}{CN_ext}"

    if not cert_filename:
        cert_filename = f"{CN}{CN_ext}"

    if not replace and os.path.exists(
        os.path.join(
            os.path.sep.join(
                "{}/{}/certs/{}.crt".format(
                    cert_base_path(), ca_name, cert_filename
                ).split("/")
            )
        )
    ):
        return f'Certificate "{cert_filename}" already exists'

    try:
        maybe_fix_ssl_version(ca_name, cacert_path=cacert_path, ca_filename=ca_filename)
        with salt.utils.files.fopen(
            f"{cert_base_path()}/{ca_name}/{ca_filename}.crt"
        ) as fhr:
            ca_cert = OpenSSL.crypto.load_certificate(
                OpenSSL.crypto.FILETYPE_PEM, fhr.read()
            )
        with salt.utils.files.fopen(
            f"{cert_base_path()}/{ca_name}/{ca_filename}.key"
        ) as fhr:
            ca_key = OpenSSL.crypto.load_privatekey(
                OpenSSL.crypto.FILETYPE_PEM, fhr.read()
            )
    except OSError:
        ret["retcode"] = 1
        ret["comment"] = f'There is no CA named "{ca_name}"'
        return ret

    try:
        csr_path = f"{cert_path}/{csr_filename}.csr"
        with salt.utils.files.fopen(csr_path) as fhr:
            req = OpenSSL.crypto.load_certificate_request(
                OpenSSL.crypto.FILETYPE_PEM, fhr.read()
            )
    except OSError:
        ret["retcode"] = 1
        ret["comment"] = 'There is no CSR that matches the CN "{}"'.format(
            cert_filename
        )
        return ret

    exts = []
    try:
        exts.extend(req.get_extensions())
    except AttributeError:
        try:
            # see: http://bazaar.launchpad.net/~exarkun/pyopenssl/master/revision/189
            # support is there from quite a long time, but without API
            # so we mimic the newly get_extensions method present in ultra
            # recent pyopenssl distros
            log.info(
                "req.get_extensions() not supported in pyOpenSSL versions "
                "prior to 0.15. Processing extensions internally. "
                "Your version: %s",
                OpenSSL_version,
            )

            native_exts_obj = OpenSSL._util.lib.X509_REQ_get_extensions(req._req)
            for i in range(OpenSSL._util.lib.sk_X509_EXTENSION_num(native_exts_obj)):
                ext = OpenSSL.crypto.X509Extension.__new__(OpenSSL.crypto.X509Extension)
                ext._extension = OpenSSL._util.lib.sk_X509_EXTENSION_value(
                    native_exts_obj, i
                )
                exts.append(ext)
        except Exception:  # pylint: disable=broad-except
            log.error(
                "X509 extensions are unsupported in pyOpenSSL "
                "versions prior to 0.14. Upgrade required to "
                "use extensions. Current version: %s",
                OpenSSL_version,
            )

    cert = OpenSSL.crypto.X509()
    cert.set_version(2)
    cert.set_subject(req.get_subject())
    cert.gmtime_adj_notBefore(0)
    cert.gmtime_adj_notAfter(int(days) * 24 * 60 * 60)
    cert.set_serial_number(_new_serial(ca_name))
    cert.set_issuer(ca_cert.get_subject())
    cert.set_pubkey(req.get_pubkey())

    if hasattr(cert, "add_extensions"):
        # pyOpenSSL >= 26 removed X509.add_extensions; see create_ca() above.
        cert.add_extensions(exts)
    elif exts:
        log.warning(
            "pyOpenSSL %s no longer exposes X509.add_extensions; signed "
            "certificate will not carry the v3 extensions from the CSR. "
            "Use the x509_v2 state module if you need them.",
            OpenSSL_version,
        )

    cert.sign(ca_key, salt.utils.stringutils.to_str(digest))

    cert_full_path = f"{cert_path}/{cert_filename}.crt"

    fp = os.open(cert_full_path, os.O_CREAT | os.O_RDWR, 0o644)
    with salt.utils.files.fopen(fp, "wb+") as crt:
        crt.write(
            salt.utils.stringutils.to_bytes(
                OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_PEM, cert)
            )
        )

    _write_cert_to_database(ca_name, cert)

    return 'Created Certificate for "{}": "{}/{}.crt"'.format(
        CN, cert_path, cert_filename
    )


def create_pkcs12(ca_name, CN, passphrase="", cacert_path=None, replace=False):
    """
    Create a PKCS#12 browser certificate for a particular Certificate (CN)

    ca_name
        name of the CA
    CN
        common name matching the certificate signing request
    passphrase
        used to unlock the PKCS#12 certificate when loaded into the browser
    cacert_path
        absolute path to ca certificates root directory
    replace
        Replace this certificate even if it exists

        .. versionadded:: 2015.5.1

    If the following values were set::

        ca.cert_base_path='/etc/pki'
        ca_name='koji'
        CN='test.egavas.org'

    the resulting signed certificate would be written in the
    following location::

        /etc/pki/koji/certs/test.egavas.org.p12

    CLI Example:

    .. code-block:: bash

        salt '*' tls.create_pkcs12 test localhost
    """
    set_ca_path(cacert_path)
    if not replace and os.path.exists(f"{cert_base_path()}/{ca_name}/certs/{CN}.p12"):
        return f'Certificate "{CN}" already exists'

    try:
        with salt.utils.files.fopen(
            "{0}/{1}/{1}_ca_cert.crt".format(cert_base_path(), ca_name)
        ) as fhr:
            ca_cert = OpenSSL.crypto.load_certificate(
                OpenSSL.crypto.FILETYPE_PEM, fhr.read()
            )
    except OSError:
        return f'There is no CA named "{ca_name}"'

    try:
        with salt.utils.files.fopen(
            f"{cert_base_path()}/{ca_name}/certs/{CN}.crt"
        ) as fhr:
            cert = OpenSSL.crypto.load_certificate(
                OpenSSL.crypto.FILETYPE_PEM, fhr.read()
            )
        with salt.utils.files.fopen(
            f"{cert_base_path()}/{ca_name}/certs/{CN}.key"
        ) as fhr:
            key = OpenSSL.crypto.load_privatekey(
                OpenSSL.crypto.FILETYPE_PEM, fhr.read()
            )
    except OSError:
        return f'There is no certificate that matches the CN "{CN}"'

    p12_data = pkcs12.serialize_key_and_certificates(
        name=salt.utils.stringutils.to_bytes(CN),
        key=key.to_cryptography_key(),
        cert=cert.to_cryptography(),
        cas=[ca_cert.to_cryptography()],
        encryption_algorithm=(
            serialization.BestAvailableEncryption(
                salt.utils.stringutils.to_bytes(passphrase)
            )
            if passphrase
            else serialization.NoEncryption()
        ),
    )

    with salt.utils.files.fopen(
        f"{cert_base_path()}/{ca_name}/certs/{CN}.p12", "wb"
    ) as ofile:
        ofile.write(p12_data)

    return 'Created PKCS#12 Certificate for "{0}": "{1}/{2}/certs/{0}.p12"'.format(
        CN,
        cert_base_path(),
        ca_name,
    )


def cert_info(cert, digest="sha256"):
    """
    Return information for a particular certificate

    cert
        path to the certifiate PEM file or string

        .. versionchanged:: 2018.3.4

    digest
        what digest to use for fingerprinting

    CLI Example:

    .. code-block:: bash

        salt '*' tls.cert_info /dir/for/certs/cert.pem

    """
    # format that OpenSSL returns dates in
    date_fmt = "%Y%m%d%H%M%SZ"
    if "-----BEGIN" not in cert:
        with salt.utils.files.fopen(cert) as cert_file:
            cert = cert_file.read()
    cert = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, cert)

    issuer = {}
    for key, value in cert.get_issuer().get_components():
        if isinstance(key, bytes):
            key = salt.utils.stringutils.to_unicode(key)
        if isinstance(value, bytes):
            value = salt.utils.stringutils.to_unicode(value)
        issuer[key] = value

    subject = {}
    for key, value in cert.get_subject().get_components():
        if isinstance(key, bytes):
            key = salt.utils.stringutils.to_unicode(key)
        if isinstance(value, bytes):
            value = salt.utils.stringutils.to_unicode(value)
        subject[key] = value

    ret = {
        "fingerprint": salt.utils.stringutils.to_unicode(
            cert.digest(salt.utils.stringutils.to_str(digest))
        ),
        "subject": subject,
        "issuer": issuer,
        "serial_number": cert.get_serial_number(),
        "not_before": calendar.timegm(
            time.strptime(
                str(cert.get_notBefore().decode(__salt_system_encoding__)), date_fmt
            )
        ),
        "not_after": calendar.timegm(
            time.strptime(
                cert.get_notAfter().decode(__salt_system_encoding__), date_fmt
            )
        ),
    }

    # add additional info if your version of pyOpenSSL supports it
    if hasattr(cert, "get_extension_count"):
        ret["extensions"] = {}
        for i in range(cert.get_extension_count()):
            try:
                ext = cert.get_extension(i)
                key = salt.utils.stringutils.to_unicode(ext.get_short_name())
                ret["extensions"][key] = str(ext).strip()
            except AttributeError:
                continue

    if "subjectAltName" in ret.get("extensions", {}):
        valid_entries = ("DNS", "IP Address")
        valid_names = set()
        for name in str(ret["extensions"]["subjectAltName"]).split(", "):
            entry, name = name.split(":", 1)
            if entry not in valid_entries:
                log.error(
                    "Cert %s has an entry (%s) which does not start with %s",
                    ret["subject"],
                    name,
                    "/".join(valid_entries),
                )
            else:
                valid_names.add(name)
        ret["subject_alt_names"] = list(valid_names)

    if hasattr(cert, "get_signature_algorithm"):
        try:
            value = cert.get_signature_algorithm()
            if isinstance(value, bytes):
                value = salt.utils.stringutils.to_unicode(value)
            ret["signature_algorithm"] = value
        except AttributeError:
            # On py3 at least
            # AttributeError: cdata 'X509 *' points to an opaque type: cannot read fields
            pass

    return ret


def create_empty_crl(
    ca_name, cacert_path=None, ca_filename=None, crl_file=None, digest="sha256"
):
    """
    Create an empty Certificate Revocation List.

    .. versionadded:: 2015.8.0

    ca_name
        name of the CA
    cacert_path
        absolute path to ca certificates root directory
    ca_filename
        alternative filename for the CA

        .. versionadded:: 2015.5.3

    crl_file
        full path to the CRL file

    digest
        The message digest algorithm. Must be a string describing a digest
        algorithm supported by OpenSSL (by EVP_get_digestbyname, specifically).
        For example, "md5" or "sha1". Default: 'sha256'

    CLI Example:

    .. code-block:: bash

        salt '*' tls.create_empty_crl ca_name='koji' \
                ca_filename='ca' \
                crl_file='/etc/openvpn/team1/crl.pem'
    """

    set_ca_path(cacert_path)

    if not ca_filename:
        ca_filename = f"{ca_name}_ca_cert"

    if not crl_file:
        crl_file = f"{_cert_base_path()}/{ca_name}/crl.pem"

    if os.path.exists(f"{crl_file}"):
        return f'CRL "{crl_file}" already exists'

    try:
        with salt.utils.files.fopen(
            f"{cert_base_path()}/{ca_name}/{ca_filename}.crt"
        ) as fp_:
            ca_cert = OpenSSL.crypto.load_certificate(
                OpenSSL.crypto.FILETYPE_PEM, fp_.read()
            )
        with salt.utils.files.fopen(
            f"{cert_base_path()}/{ca_name}/{ca_filename}.key"
        ) as fp_:
            ca_key = OpenSSL.crypto.load_privatekey(
                OpenSSL.crypto.FILETYPE_PEM, fp_.read()
            )
    except OSError:
        return f'There is no CA named "{ca_name}"'

    if HAS_CRYPTOGRAPHY:
        # Load CA objects using cryptography
        with salt.utils.files.fopen(
            f"{cert_base_path()}/{ca_name}/{ca_filename}.crt", "rb"
        ) as fp_:
            ca_x509 = x509.load_pem_x509_certificate(fp_.read())
        with salt.utils.files.fopen(
            f"{cert_base_path()}/{ca_name}/{ca_filename}.key", "rb"
        ) as fp_:
            ca_key = serialization.load_pem_private_key(fp_.read(), password=None)

        builder = x509.CertificateRevocationListBuilder()
        builder = builder.issuer_name(ca_x509.subject)
        builder = builder.last_update(salt.utils.timeutil.utcnow())
        builder = builder.next_update(
            salt.utils.timeutil.utcnow() + timedelta(days=36500)
        )

        # Mapping digest strings to cryptography hashes
        hash_algo = getattr(hashes, digest.upper(), hashes.SHA256)()
        crl_obj = builder.sign(private_key=ca_key, algorithm=hash_algo)
        crl_text = crl_obj.public_bytes(serialization.Encoding.PEM)
    else:
        crl = OpenSSL.crypto.CRL()
        crl_text = crl.export(
            ca_cert,
            ca_key,
            digest=salt.utils.stringutils.to_bytes(digest),
        )

    with salt.utils.files.fopen(crl_file, "w") as f:
        f.write(salt.utils.stringutils.to_str(crl_text))

    return f'Created an empty CRL: "{crl_file}"'


def revoke_cert(
    ca_name,
    CN,
    cacert_path=None,
    ca_filename=None,
    cert_path=None,
    cert_filename=None,
    crl_file=None,
    digest="sha256",
):
    """
    Revoke a certificate.

    .. versionadded:: 2015.8.0

    ca_name
        Name of the CA.

    CN
        Common name matching the certificate signing request.

    cacert_path
        Absolute path to ca certificates root directory.

    ca_filename
        Alternative filename for the CA.

    cert_path
        Path to the cert file.

    cert_filename
        Alternative filename for the certificate, useful when using special
        characters in the CN.

    crl_file
        Full path to the CRL file.

    digest
        The message digest algorithm. Must be a string describing a digest
        algorithm supported by OpenSSL (by EVP_get_digestbyname, specifically).
        For example, "md5" or "sha1". Default: 'sha256'

    CLI Example:

    .. code-block:: bash

        salt '*' tls.revoke_cert ca_name='koji' \
                ca_filename='ca' \
                crl_file='/etc/openvpn/team1/crl.pem'

    """

    set_ca_path(cacert_path)
    ca_dir = f"{cert_base_path()}/{ca_name}"

    if ca_filename is None:
        ca_filename = f"{ca_name}_ca_cert"

    if cert_path is None:
        cert_path = f"{_cert_base_path()}/{ca_name}/certs"

    if cert_filename is None:
        cert_filename = f"{CN}"

    try:
        with salt.utils.files.fopen(
            f"{cert_base_path()}/{ca_name}/{ca_filename}.crt"
        ) as fp_:
            ca_cert = OpenSSL.crypto.load_certificate(
                OpenSSL.crypto.FILETYPE_PEM, fp_.read()
            )
        with salt.utils.files.fopen(
            f"{cert_base_path()}/{ca_name}/{ca_filename}.key"
        ) as fp_:
            ca_key = OpenSSL.crypto.load_privatekey(
                OpenSSL.crypto.FILETYPE_PEM, fp_.read()
            )
    except OSError:
        return f'There is no CA named "{ca_name}"'

    client_cert = _read_cert(f"{cert_path}/{cert_filename}.crt")
    if client_cert is None:
        return f'There is no client certificate named "{CN}"'

    index_file, expire_date, serial_number, subject = _get_basic_info(
        ca_name, client_cert, ca_dir
    )

    index_serial_subject = f"{serial_number}\tunknown\t{subject}"
    index_v_data = f"V\t{expire_date}\t\t{index_serial_subject}"
    index_r_data_pattern = re.compile(
        r"R\t" + expire_date + r"\t\d{12}Z\t" + re.escape(index_serial_subject)
    )
    index_r_data = "R\t{}\t{}\t{}".format(
        expire_date,
        _four_digit_year_to_two_digit(salt.utils.timeutil.utcnow()),
        index_serial_subject,
    )

    ret = {}
    with salt.utils.files.fopen(index_file) as fp_:
        for line in fp_:
            line = salt.utils.stringutils.to_unicode(line)
            if index_r_data_pattern.match(line):
                revoke_date = line.split("\t")[2]
                try:
                    datetime.strptime(revoke_date, two_digit_year_fmt)
                    return '"{}/{}.crt" was already revoked, serial number: {}'.format(
                        cert_path, cert_filename, serial_number
                    )
                except ValueError:
                    ret["retcode"] = 1
                    ret["comment"] = (
                        "Revocation date '{}' does not matchformat '{}'".format(
                            revoke_date, two_digit_year_fmt
                        )
                    )
                    return ret
            elif index_serial_subject in line:
                __salt__["file.replace"](
                    index_file, index_v_data, index_r_data, backup=False
                )
                break

    if HAS_CRYPTOGRAPHY:
        # Load CA objects using cryptography
        with salt.utils.files.fopen(
            f"{cert_base_path()}/{ca_name}/{ca_filename}.crt", "rb"
        ) as fp_:
            ca_x509 = x509.load_pem_x509_certificate(fp_.read())
        with salt.utils.files.fopen(
            f"{cert_base_path()}/{ca_name}/{ca_filename}.key", "rb"
        ) as fp_:
            ca_key = serialization.load_pem_private_key(fp_.read(), password=None)

        builder = x509.CertificateRevocationListBuilder()
        builder = builder.issuer_name(ca_x509.subject)
        builder = builder.last_update(salt.utils.timeutil.utcnow())
        builder = builder.next_update(
            salt.utils.timeutil.utcnow() + timedelta(days=36500)
        )

        with salt.utils.files.fopen(index_file) as fp_:
            for line in fp_:
                line = salt.utils.stringutils.to_unicode(line)
                if line.startswith("R"):
                    fields = line.split("\t")
                    # fields[3] is serial, fields[2] is revocation date
                    serial = int(fields[3], 16)
                    # Cryptography supports up to 159 bits. Truncate if necessary.
                    if serial.bit_length() >= 160:
                        serial = serial & ((1 << 159) - 1)
                    revocation_date = datetime.strptime(fields[2], two_digit_year_fmt)
                    revoked_builder = x509.RevokedCertificateBuilder()
                    revoked_builder = revoked_builder.serial_number(serial)
                    revoked_builder = revoked_builder.revocation_date(revocation_date)
                    builder = builder.add_revoked_certificate(revoked_builder.build())

        # Mapping digest strings to cryptography hashes
        hash_algo = getattr(hashes, digest.upper(), hashes.SHA256)()
        crl_obj = builder.sign(private_key=ca_key, algorithm=hash_algo)
        crl_text = crl_obj.public_bytes(serialization.Encoding.PEM)
    else:
        crl = OpenSSL.crypto.CRL()

        with salt.utils.files.fopen(index_file) as fp_:
            for line in fp_:
                line = salt.utils.stringutils.to_unicode(line)
                if line.startswith("R"):
                    fields = line.split("\t")
                    revoked = OpenSSL.crypto.Revoked()
                    revoked.set_serial(salt.utils.stringutils.to_bytes(fields[3]))
                    revoke_date_2_digit = datetime.strptime(
                        fields[2], two_digit_year_fmt
                    )
                    revoked.set_rev_date(
                        salt.utils.stringutils.to_bytes(
                            revoke_date_2_digit.strftime(four_digit_year_fmt)
                        )
                    )
                    crl.add_revoked(revoked)

        crl_text = crl.export(
            ca_cert, ca_key, digest=salt.utils.stringutils.to_bytes(digest)
        )

    if crl_file is None:
        crl_file = f"{_cert_base_path()}/{ca_name}/crl.pem"

    if os.path.isdir(crl_file):
        ret["retcode"] = 1
        ret["comment"] = f'crl_file "{crl_file}" is an existing directory'
        return ret

    with salt.utils.files.fopen(crl_file, "w") as fp_:
        fp_.write(salt.utils.stringutils.to_str(crl_text))

    return 'Revoked Certificate: "{}/{}.crt", serial number: {}'.format(
        cert_path, cert_filename, serial_number
    )


if __name__ == "__main__":
    # create_ca('koji', days=365, **cert_sample_meta)
    create_csr(
        "koji",
        CN="test_system",
        C="US",
        ST="Utah",
        L="Centerville",
        O="SaltStack",
        OU=None,
        emailAddress="test_system@saltproject.io",
    )
    create_ca_signed_cert("koji", "test_system")
    create_pkcs12("koji", "test_system", passphrase="test")
