import base64
import copy
import ipaddress
import logging
import os.path
import re
from collections import OrderedDict
from datetime import datetime, timedelta, timezone
from enum import Enum
from urllib.parse import quote, urlsplit, urlunsplit

import cryptography
from cryptography import x509 as cx509
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat import asn1
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec, ed448, ed25519, padding, rsa
from cryptography.hazmat.primitives.serialization import pkcs7, pkcs12
from cryptography.x509.oid import SubjectInformationAccessOID

import salt.utils.files
import salt.utils.immutabletypes as immutabletypes
import salt.utils.stringutils
import salt.utils.versions
from salt.exceptions import CommandExecutionError, SaltInvocationError

try:
    import idna

    HAS_IDNA = True
except ImportError:
    HAS_IDNA = False

CRYPTOGRAPHY_VERSION = tuple(int(x) for x in cryptography.__version__.split("."))

log = logging.getLogger(__name__)

NAME_ATTRS_ALT_NAMES = immutabletypes.freeze(
    {
        "CN": ("commonName",),
        "L": ("localityName",),
        "ST": ("stateOrProvinceName",),
        "O": ("organizationName",),
        "OU": ("organizationUnitName",),
        "GN": ("givenName",),
        "SN": ("surname",),
        "MAIL": ("Email", "emailAddress"),
        "SERIALNUMBER": ("serialNumber",),
    }
)

NAME_ATTRS_OID = immutabletypes.freeze(
    OrderedDict(
        [
            ("C", cx509.NameOID.COUNTRY_NAME),
            ("ST", cx509.NameOID.STATE_OR_PROVINCE_NAME),
            ("L", cx509.NameOID.LOCALITY_NAME),
            ("STREET", cx509.NameOID.STREET_ADDRESS),
            ("O", cx509.NameOID.ORGANIZATION_NAME),
            ("OU", cx509.NameOID.ORGANIZATIONAL_UNIT_NAME),
            ("CN", cx509.NameOID.COMMON_NAME),
            ("MAIL", cx509.NameOID.EMAIL_ADDRESS),
            ("SN", cx509.NameOID.SURNAME),
            ("GN", cx509.NameOID.GIVEN_NAME),
            ("UID", cx509.NameOID.USER_ID),
            ("SERIALNUMBER", cx509.NameOID.SERIAL_NUMBER),
        ]
    )
)

EXTENSIONS_ALT_NAMES = immutabletypes.freeze(
    {
        "basicConstraints": ("X509v3 Basic Constraints",),
        "keyUsage": ("X509v3 Key Usage",),
        "extendedKeyUsage": ("X509v3 Extended Key Usage",),
        "subjectKeyIdentifier": ("X509v3 Subject Key Identifier",),
        "authorityKeyIdentifier": ("X509v3 Authority Key Identifier",),
        # issuserAltName was a typo in the old x509 modules
        "issuerAltName": ("X509v3 Issuer Alternative Name", "issuserAltName"),
        "authorityInfoAccess": ("Authority Information Access",),
        "subjectAltName": ("X509v3 Subject Alternative Name",),
        "crlDistributionPoints": ("X509v3 CRL Distribution Points",),
        "issuingDistributionPoint": ("X509v3 Issuing Distribution Point",),
        "certificatePolicies": ("X509v3 Certificate Policies",),
        "policyConstraints": ("X509v3 Policy Constraints",),
        "inhibitAnyPolicy": ("X509v3 Inhibit Any Policy",),
        "nameConstraints": ("X509v3 Name Constraints",),
        "noCheck": ("OCSP No Check",),
        "tlsfeature": ("TLS Feature",),
        "nsComment": ("Netscape Comment",),
        "nsCertType": ("Netscape Certificate Type",),
        "cRLNumber": ("X509v3 CRLNumber",),
        "deltaCRLIndicator": ("X509v3 Delta CRL Indicator",),
        "freshestCRL": ("x509v3 Freshest CRL",),
    }
)

EXTENSIONS_OID = immutabletypes.freeze(
    {
        "basicConstraints": cx509.ExtensionOID.BASIC_CONSTRAINTS,
        "keyUsage": cx509.ExtensionOID.KEY_USAGE,
        "extendedKeyUsage": cx509.ExtensionOID.EXTENDED_KEY_USAGE,
        "subjectKeyIdentifier": cx509.ExtensionOID.SUBJECT_KEY_IDENTIFIER,
        "authorityKeyIdentifier": cx509.ExtensionOID.AUTHORITY_KEY_IDENTIFIER,
        "issuerAltName": cx509.ExtensionOID.ISSUER_ALTERNATIVE_NAME,
        "authorityInfoAccess": cx509.ExtensionOID.AUTHORITY_INFORMATION_ACCESS,
        "subjectAltName": cx509.ExtensionOID.SUBJECT_ALTERNATIVE_NAME,
        "crlDistributionPoints": cx509.ExtensionOID.CRL_DISTRIBUTION_POINTS,
        "issuingDistributionPoint": cx509.ExtensionOID.ISSUING_DISTRIBUTION_POINT,
        "certificatePolicies": cx509.ExtensionOID.CERTIFICATE_POLICIES,
        "policyConstraints": cx509.ExtensionOID.POLICY_CONSTRAINTS,
        "inhibitAnyPolicy": cx509.ExtensionOID.INHIBIT_ANY_POLICY,
        "nameConstraints": cx509.ExtensionOID.NAME_CONSTRAINTS,
        "noCheck": cx509.ExtensionOID.OCSP_NO_CHECK,
        "tlsfeature": cx509.ExtensionOID.TLS_FEATURE,
        "nsComment": None,
        "nsCertType": None,
        "cRLNumber": cx509.ExtensionOID.CRL_NUMBER,
        "deltaCRLIndicator": cx509.ExtensionOID.DELTA_CRL_INDICATOR,
        "freshestCRL": cx509.ExtensionOID.FRESHEST_CRL,
    }
)

EXTENSIONS_CRL_ENTRY_OID = immutabletypes.freeze(
    {
        "certificateIssuer": cx509.CRLEntryExtensionOID.CERTIFICATE_ISSUER,
        "CRLReason": cx509.CRLEntryExtensionOID.CRL_REASON,
        "invalidityDate": cx509.CRLEntryExtensionOID.INVALIDITY_DATE,
    }
)


EXTENDED_KEY_USAGE_OID = immutabletypes.freeze(
    {
        "serverAuth": cx509.ObjectIdentifier("1.3.6.1.5.5.7.3.1"),
        "clientAuth": cx509.ObjectIdentifier("1.3.6.1.5.5.7.3.2"),
        "codeSigning": cx509.ObjectIdentifier("1.3.6.1.5.5.7.3.3"),
        "emailProtection": cx509.ObjectIdentifier("1.3.6.1.5.5.7.3.4"),
        "timeStamping": cx509.ObjectIdentifier("1.3.6.1.5.5.7.3.8"),
        "OCSPSigning": cx509.ObjectIdentifier("1.3.6.1.5.5.7.3.9"),
        "msSmartcardLogin": cx509.ObjectIdentifier("1.3.6.1.4.1.311.20.2.2"),
        "pkInitKDC": cx509.ObjectIdentifier("1.3.6.1.5.2.3.5"),
        "ipsecIKE": cx509.ObjectIdentifier("1.3.6.1.5.5.7.3.17"),
        "msCodeInd": cx509.ObjectIdentifier("1.3.6.1.4.1.311.2.1.21"),
        "msCodeCom": cx509.ObjectIdentifier("1.3.6.1.4.1.311.2.1.22"),
        "msCTLSign": cx509.ObjectIdentifier("1.3.6.1.4.1.311.10.3.1"),
        "msEFS": cx509.ObjectIdentifier("1.3.6.1.4.1.311.10.3.4"),
    }
)

ACCESS_OID = immutabletypes.freeze(
    {
        "OCSP": cx509.AuthorityInformationAccessOID.OCSP,
        "caIssuers": cx509.AuthorityInformationAccessOID.CA_ISSUERS,
        "caRepository": SubjectInformationAccessOID.CA_REPOSITORY,
    }
)

CERT_EXTS = (
    "basicConstraints",
    "keyUsage",
    "extendedKeyUsage",
    "subjectKeyIdentifier",
    "authorityKeyIdentifier",
    "issuerAltName",
    "authorityInfoAccess",
    "subjectAltName",
    "crlDistributionPoints",
    "certificatePolicies",
    "policyConstraints",
    "inhibitAnyPolicy",
    "nameConstraints",
    "noCheck",
    "tlsfeature",
    "nsComment",
    "nsCertType",
)

CRL_EXTS = (
    "authorityKeyIdentifier",
    "authorityInfoAccess",
    "cRLNumber",
    "deltaCRLIndicator",
    "freshestCRL",
    "issuerAltName",
    "issuingDistributionPoint",
)

CSR_FORBIDDEN = (
    "authorityInfoAccess",
    "authorityKeyIdentifier",
    "issuerAltName",
    "crlDistributionPoints",
)


class KEY_TYPE(Enum):
    RSA = 1
    EC = 2
    ED25519 = 3
    ED448 = 4


PEM_BEGIN = b"-----BEGIN"
PEM_END = b"-----END"

TIME_FMT = "%Y-%m-%d %H:%M:%S"


class DeserializationError(SaltInvocationError):
    """
    Raised when the input format of a private key/certificate/CSR/CRL is unsupported
    or could not be loaded for various reasons.
    """


class CertDeserializationError(DeserializationError):
    """
    Raised when the input format of a certificate is unsupported
    or could not be loaded for various reasons.
    """


class PrivDeserializationError(DeserializationError):
    """
    Raised when the input format of a private key is unsupported
    or could not be loaded for various reasons.
    """


class PubDeserializationError(DeserializationError):
    """
    Raised when the input format of a public key is unsupported
    or could not be loaded for various reasons.
    """


class CRLDeserializationError(DeserializationError):
    """
    Raised when the input format of a CRL is unsupported
    or could not be loaded for various reasons.
    """


class CSRDeserializationError(DeserializationError):
    """
    Raised when the input format of a CSR is unsupported
    or could not be loaded for various reasons.
    """


class PasswordError(SaltInvocationError):
    """
    Raised when a private key or PKCS#12 container could be loaded, but the provided password has an issue.
    """


class InvalidPassword(PasswordError):
    """
    Raised when the provided password could not be used to decrypt an encrypted private key/PKCS#12 container.
    """

    def __init__(self):
        super().__init__("Bad decrypt - is the password correct?")


class MissingPassword(PasswordError):
    """
    Raised when a private key is encrypted, but no password was provided.
    At least in cryptography releases 46+, this cannot be determined for PKCS#12 format,
    where it's always InvalidPassword.
    """

    def __init__(self):
        super().__init__("Private key is encrypted. Please provide a password.")


class SuperfluousPassword(PasswordError):
    """
    Raised when a private key is not encrypted, but a password was provided.
    Cannot be determined for PKCS#12 format, where it's always InvalidPassword.
    """

    def __init__(self):
        super().__init__("Private key is unencrypted. Please remove the password.")


def ensure_cert_kwargs_compat(kwargs):
    """
    Ensures the deprecated long form of Name Attribute and
    extension definitions is still recognized, but warned about.
    """
    for name, long_names in NAME_ATTRS_ALT_NAMES.items():
        for long_name in long_names:
            if long_name in kwargs:
                salt.utils.versions.warn_until(
                    3009,
                    f"Found {long_name} in keyword args. Please migrate to the short name: {name}",
                )
                kwargs[name] = kwargs.pop(long_name)

    for extname, long_names in EXTENSIONS_ALT_NAMES.items():
        for long_name in long_names:
            if long_name in kwargs:
                salt.utils.versions.warn_until(
                    3009,
                    f"Found {long_name} in keyword args. Please migrate to the short name: {extname}",
                )
                kwargs[extname] = kwargs.pop(long_name)
    return kwargs


def build_crt(
    signing_private_key,
    skip_load_signing_private_key=False,
    signing_private_key_passphrase=None,
    signing_cert=None,
    public_key=None,
    private_key=None,
    private_key_passphrase=None,
    csr=None,
    subject=None,
    serial_number=None,
    not_before=None,
    not_after=None,
    days_valid=30,
    **kwargs,
):
    """
    Parse the input into a CertificateBuilder, which can be used
    to sign the certificate or be inspected for changes.

    Also returns signing private key (if available), associated private key
    and associated signing certificate.
    """
    ca_pub = None
    self_signed = False
    if not skip_load_signing_private_key:
        # skipping this is necessary for the state module to be able to check
        # for changes when issuing certs from remote
        signing_private_key = load_privkey(
            signing_private_key, passphrase=signing_private_key_passphrase
        )
    else:
        signing_private_key = None
    serial_number = _get_serial_number(serial_number)
    # optionally holds cert-associated private key, needed for pkcs12 serialization later
    private_key_loaded = None

    if csr:
        csr = load_csr(csr)
    if signing_cert:
        signing_cert = load_cert(signing_cert)
    else:
        self_signed = True
        public_key = signing_private_key.public_key()
        ca_pub = public_key

    if self_signed:
        private_key_loaded = signing_private_key
    elif private_key:
        private_key_loaded = load_privkey(
            private_key, passphrase=private_key_passphrase
        )
        public_key = private_key_loaded.public_key()
    elif public_key:
        public_key = load_pubkey(public_key)
    elif csr:
        public_key = csr.public_key()
    else:
        raise SaltInvocationError(
            "This certificate is not self-signed (signing_cert is set) and thus "
            "needs public_key, private_key or csr to derive public key to sign"
        )

    if (
        not skip_load_signing_private_key
        and not self_signed
        and not is_pair(signing_cert.public_key(), signing_private_key)
    ):
        raise SaltInvocationError(
            "Signing private key does not match the certificate's public key"
        )

    builder = cx509.CertificateBuilder(
        serial_number=serial_number, public_key=public_key
    )

    subject_name = _get_dn(subject or kwargs)
    builder = builder.subject_name(subject_name).issuer_name(
        signing_cert.subject if not self_signed else subject_name
    )

    not_before = _strptime(not_before, "not_before") or datetime.now(tz=timezone.utc)
    not_after = _strptime(not_after, "not_after") or (
        datetime.now(tz=timezone.utc) + timedelta(days=days_valid)
    )
    builder = builder.not_valid_before(not_before).not_valid_after(not_after)

    ext_present = []
    for extname in EXTENSIONS_OID:
        if extname not in CERT_EXTS:
            continue
        if extname in kwargs:
            val = kwargs[extname]
            # signing policies need to be able to force-unset extensions
            if val is None:
                continue
            ext, critical = _create_extension(
                extname,
                val,
                ca_crt=signing_cert,
                subject_pubkey=public_key,
                ca_pub=ca_pub,
            )
            builder = builder.add_extension(
                ext,
                critical=critical,
            )
            ext_present.append(extname)
    if csr:
        for extname, oid in EXTENSIONS_OID.items():
            if any(
                (
                    extname in ext_present,
                    extname not in CERT_EXTS,
                    extname in CSR_FORBIDDEN,
                )
            ):
                continue
            try:
                ext = csr.extensions.get_extension_for_oid(oid)
                builder = builder.add_extension(ext.value, ext.critical)
            except cx509.ExtensionNotFound:
                pass
    return builder, signing_private_key, private_key_loaded, signing_cert


def build_csr(private_key, private_key_passphrase=None, subject=None, **kwargs):
    """
    Parse the input into a CertificateSigningRequestBuilder, which can be used
    to sign the CSR or be inspected for changes.

    Also returns associated private key.
    """
    private_key = load_privkey(private_key, passphrase=private_key_passphrase)
    public_key = private_key.public_key()
    builder = cx509.CertificateSigningRequestBuilder()
    subject_name = _get_dn(subject or kwargs)
    builder = builder.subject_name(subject_name)
    for extname, _ in EXTENSIONS_OID.items():
        if any(
            (
                extname not in CERT_EXTS,
                extname in CSR_FORBIDDEN,
            )
        ):
            continue
        if extname in kwargs:
            ext, critical = _create_extension(
                extname, kwargs[extname], subject_pubkey=public_key
            )
            builder = builder.add_extension(
                ext,
                critical=critical,
            )
    return builder, private_key


def build_crl(
    signing_private_key,
    revoked,
    signing_cert,
    signing_private_key_passphrase=None,
    include_expired=False,
    days_valid=100,
    extensions=None,
):
    """
    Parse the input into a CertificateRevocationListBuilder, which can be used
    to sign the CRL or be inspected for changes.

    Also returns signing private key.
    """
    extensions = extensions or {}
    signing_cert = load_cert(signing_cert)
    signing_private_key = load_privkey(
        signing_private_key, passphrase=signing_private_key_passphrase
    )
    if not is_pair(signing_cert.public_key(), signing_private_key):
        raise SaltInvocationError(
            "Signing private key does not match the certificate's public key"
        )
    builder = cx509.CertificateRevocationListBuilder()
    builder = builder.issuer_name(signing_cert.subject)
    builder = builder.last_update(datetime.now(tz=timezone.utc))
    builder = builder.next_update(
        datetime.now(tz=timezone.utc) + timedelta(days=days_valid)
    )
    for rev in revoked:
        serial_number = rev.get("serial_number")
        not_after = _strptime(rev.get("not_after"), "not_after")
        if "certificate" in rev:
            rev_cert = load_cert(rev["certificate"])
            serial_number = rev_cert.serial_number
            try:
                not_after = rev_cert.not_valid_after_utc
            except AttributeError:
                # naive datetime object, release <42 (it's always UTC)
                not_after = rev_cert.not_valid_after.replace(tzinfo=timezone.utc)
        if not serial_number:
            raise SaltInvocationError("Need serial_number or certificate")
        serial_number = _get_serial_number(serial_number)
        if not_after and not include_expired:
            if datetime.now(tz=timezone.utc) > not_after:
                continue
        revocation_date = _strptime(
            rev.get("revocation_date"), "revocation_date"
        ) or datetime.now(tz=timezone.utc)
        revoked_cert = cx509.RevokedCertificateBuilder(
            serial_number=serial_number, revocation_date=revocation_date
        )
        for extname, val in rev.get("extensions", {}).items():
            if extname not in EXTENSIONS_CRL_ENTRY_OID:
                log.warning("Ignoring invalid CRL entry extension: %s", extname)
                continue
            ext, critical = _create_extension(extname, val)
            revoked_cert = revoked_cert.add_extension(ext, critical=critical)
        builder = builder.add_revoked_certificate(revoked_cert.build())
    for extname, val in extensions.items():
        if extname not in CRL_EXTS:
            log.warning("Ignoring invalid CRL extension: %s", extname)
            continue
        ext, critical = _create_extension(extname, val, ca_crt=signing_cert)
        builder = builder.add_extension(
            ext,
            critical=critical,
        )
    return builder, signing_private_key


def generate_rsa_privkey(keysize=2048):
    """
    Generate an RSA private key
    """
    if keysize not in [2048, 3072, 4096]:
        raise SaltInvocationError(
            "RSA key size must be either 2048, 3072 or 4096 bits."
        )
    return rsa.generate_private_key(public_exponent=65537, key_size=keysize)


def generate_ec_privkey(keysize=256):
    """
    Generate an elliptic curve private key
    """
    if keysize not in [256, 384, 521]:
        raise SaltInvocationError("EC key size must be either 256, 384, 521 bits.")
    return ec.generate_private_key(getattr(ec, f"SECP{keysize}R1")())


def generate_ed25519_privkey():
    """
    Generate an ed25519 private key
    """
    return ed25519.Ed25519PrivateKey.generate()


def generate_ed448_privkey():
    """
    Generate an ed448 private key
    """
    return ed448.Ed448PrivateKey.generate()


def get_hashing_algorithm(digest):
    """
    Returns an instance of a hashing algorithm, if available
    """
    try:
        return getattr(hashes, digest.upper())()
    except AttributeError as err:
        raise CommandExecutionError(
            "The selected hashing algorithm does not exist in the cryptography "
            "python library"
        ) from err


def get_key_type(key, as_string=False):
    """
    Checks which type of private/public key a class instance is.
    Returns None if it is not a valid key.
    """
    key_type = None
    if isinstance(key, (rsa.RSAPrivateKey, rsa.RSAPublicKey)):
        key_type = "rsa"
    if isinstance(key, (ec.EllipticCurvePrivateKey, ec.EllipticCurvePublicKey)):
        key_type = "ec"
    if isinstance(key, (ed25519.Ed25519PrivateKey, ed25519.Ed25519PublicKey)):
        key_type = "ed25519"
    if isinstance(key, (ed448.Ed448PrivateKey, ed448.Ed448PublicKey)):
        key_type = "ed448"
    if key_type is None or as_string:
        return key_type
    return getattr(KEY_TYPE, key_type.upper())


def is_pair(pubkey, privkey):
    """
    Checks whether a public key belongs to a private key
    """
    privkey_type = get_key_type(privkey)
    if privkey_type is None:
        raise SaltInvocationError("Did not recognize key type")
    return match_pubkey(pubkey, privkey.public_key())


def match_pubkey(pubkey_a, pubkey_b):
    """
    Checks whether two public keys are the same
    """
    pubkey_a_type = get_key_type(pubkey_a)
    pubkey_b_type = get_key_type(pubkey_b)

    if pubkey_a_type is None or pubkey_b_type is None:
        raise SaltInvocationError("Did not recognize key type")
    if pubkey_a_type != pubkey_b_type:
        return False
    if pubkey_a_type in [KEY_TYPE.RSA, KEY_TYPE.EC]:
        return pubkey_a.public_numbers() == pubkey_b.public_numbers()
    return to_pem(pubkey_a) == to_pem(pubkey_b)


def merge_signing_policy(policy, kwargs):
    """
    Merge a signing policy, taking care that the different methods
    of specifying RDN do not lead to unexpected results.

    This is found in utils since the state module needs
    access as well to check for expected changes.
    """
    if not policy:
        return kwargs
    # ensure we don't modify data that is used elsewhere
    policy = copy.deepcopy(policy)

    # Don't immediately break for the long form of name attributes
    for name, long_names in NAME_ATTRS_ALT_NAMES.items():
        for long_name in long_names:
            if long_name in kwargs:
                salt.utils.versions.warn_until(
                    3009,
                    f"Found {long_name} in keyword args. Please migrate to the short name: {name}",
                )
                kwargs[name] = kwargs.pop(long_name)

    # Don't immediately break for the long form of extensions
    for extname, long_names in EXTENSIONS_ALT_NAMES.items():
        for long_name in long_names:
            if long_name in kwargs:
                salt.utils.versions.warn_until(
                    3009,
                    f"Found {long_name} in keyword args. Please migrate to the short name: {extname}",
                )
                kwargs[extname] = kwargs.pop(long_name)

    if "subject" in kwargs:
        # a) ensure subject in kwargs does not override CN etc from signing policy
        if any(x in policy for x in NAME_ATTRS_OID):
            kwargs.pop("subject")
        # b) subject is not enforced or if it is a string, it cannot be overridden
        elif "subject" not in policy or not isinstance(policy["subject"], (dict, list)):
            pass
        # c) if both subject sources are of the same time, update dicts or merge lists
        else:
            try:
                kwargs["subject"].update(policy["subject"])
                policy.pop("subject")
            except (AttributeError, ValueError):
                try:
                    kwargs["subject"] = policy["subject"] + kwargs["subject"]
                    policy.pop("subject")
                except TypeError:
                    pass
        # d) otherwise enforce subject from signing policy
    kwargs.update(policy)
    return kwargs


def to_pem(pub_or_cert):
    """
    Returns the PEM-encoded serialization of a public key, certificate,
    certificate signing request or certificate revocation list.
    This does not work for private keys.
    """
    try:
        return pub_or_cert.public_bytes(
            serialization.Encoding.PEM,
            format=serialization.PublicFormat.SubjectPublicKeyInfo,
        )
    except (AttributeError, TypeError):
        pass
    try:
        return pub_or_cert.public_bytes(serialization.Encoding.PEM)
    except (AttributeError, TypeError):
        pass
    raise SaltInvocationError("Could not serialize parameter to PEM")


def to_der(pub_or_cert):
    """
    Returns the DER-encoded serialization of a public key, certificate,
    certificate signing request or certificate revocation list.
    This does not work for private keys.
    """
    try:
        return pub_or_cert.public_bytes(
            serialization.Encoding.DER,
            format=serialization.PublicFormat.SubjectPublicKeyInfo,
        )
    except (AttributeError, TypeError):
        pass
    try:
        return pub_or_cert.public_bytes(serialization.Encoding.DER)
    except (AttributeError, TypeError):
        pass
    raise SaltInvocationError("Could not serialize parameter to DER")


def load_privkey(pk, passphrase=None, get_encoding=False):
    """
    Return a private key instance from

    * a class instance
    * a file path on the local system
    * a string (PEM)
    * bytes (hex, base64, raw)

    Valid encodings are PEM, DER and PKCS12.
    """
    if hasattr(pk, "private_bytes"):
        if isinstance(
            pk,
            (
                rsa.RSAPrivateKey,
                ec.EllipticCurvePrivateKey,
                ed25519.Ed25519PrivateKey,
                ed448.Ed448PrivateKey,
            ),
        ):
            if get_encoding:
                return pk, None, None
            return pk
        raise SaltInvocationError(
            f"Passed object is not a known private key, but {pk.__class__.__name__}"
        )
    pk = load_file_or_bytes(pk)
    passphrase = passphrase.encode() if passphrase is not None else None
    # PEM
    if PEM_BEGIN in pk:
        try:
            pk = serialization.load_pem_private_key(pk, password=passphrase)
            if get_encoding:
                return pk, "pem", None
            return pk
        except (ValueError, TypeError) as err:
            err_str = str(err)
            if "Bad decrypt" in err_str or "Incorrect password" in err_str:
                raise InvalidPassword() from err
            if "private key is encrypted" in err_str:
                raise MissingPassword() from err
            if "but private key is not encrypted" in err_str:
                raise SuperfluousPassword() from err
            raise PrivDeserializationError(
                "Could not load PEM-encoded private key"
            ) from err
    # PKCS12
    try:
        # v36+ - there is pkcs12.load_key_and_certificates in 2.5+ @TODO?
        loaded = pkcs12.load_pkcs12(pk, password=passphrase)
        if not loaded.key:
            raise CommandExecutionError(
                "PKCS12-encoded blob does not contain a private key."
            )
        if get_encoding:
            return loaded.key, "pkcs12", loaded
        return loaded.key
    except ValueError as err:
        err_str = str(err)
        if "Bad decrypt" in err_str or "Invalid password" in err_str:
            raise InvalidPassword() from err
    except TypeError as err:
        if "private key is encrypted" in str(err):
            raise MissingPassword() from err
    except AttributeError:
        pass
    # DER
    try:
        pk = serialization.load_der_private_key(pk, password=passphrase)
        if get_encoding:
            return pk, "der", None
        return pk
    except ValueError as err:
        err_str = str(err)
        if "Bad decrypt" in err_str or "Incorrect password" in err_str:
            raise InvalidPassword() from err
    except TypeError as err:
        err_str = str(err)
        if "private key is encrypted" in err_str:
            raise MissingPassword() from err
        if "private key is not encrypted" in err_str:
            raise SuperfluousPassword() from err
    # nothing worked
    raise PrivDeserializationError(
        "Could not deserialize binary data, neither as DER nor PKCS#12."
    )


def load_pubkey(pk, get_encoding=False):
    """
    Return a public key instance from

    * a class instance
    * a file path on the local system
    * a string (PEM)
    * bytes (hex, base64, raw)

    Valid encodings are PEM and DER.
    """
    if hasattr(pk, "public_bytes"):
        if isinstance(
            pk,
            (
                rsa.RSAPublicKey,
                ec.EllipticCurvePublicKey,
                ed25519.Ed25519PublicKey,
                ed448.Ed448PublicKey,
            ),
        ):
            return pk
        raise SaltInvocationError(
            f"Passed object is not a public key, but {pk.__class__.__name__}"
        )
    pk = load_file_or_bytes(pk)
    if PEM_BEGIN in pk:
        try:
            ret = serialization.load_pem_public_key(pk)
        except ValueError as err:
            raise PubDeserializationError(
                "Could not load PEM-encoded public key."
            ) from err
        if get_encoding:
            return ret, "pem"
        return ret
    try:
        ret = serialization.load_der_public_key(pk)
    except ValueError as err:
        raise PubDeserializationError("Could not load DER-encoded public key.") from err
    if get_encoding:
        return ret, "der"
    return ret


def order_certs_naively(bundle, allow_orphans=True, require_leaf=True):
    """
    Deterministically order certificates in a bundle using a naive algorithm.
    This is not a chain building algorithm! It just selects the longest chain
    of direct certification, preferring leaves by default, and appends all
    orphans ordered by their fingerprints, if orphans are allowed.

    bundle
        A set of cryptography.x509.Certificate objects to order.

    allow_orphans
        Do not require all certificates to build a single chain. Defaults to true.

    require_leaf
        Require that a path begins with a certificate that itself has not
        been used to issue another certificate in the bundle. Defaults to true.
    """
    if len(bundle) < 2:
        return list(bundle)

    def _directly_issued_by(subject, issuer):
        if subject.issuer != issuer.subject:
            return False
        try:
            subject.verify_directly_issued_by(issuer)
        except (InvalidSignature, TypeError, ValueError):
            return False
        return True

    def _fp(cert):
        return cert.fingerprint(hashes.SHA256())

    ordered_bundle = tuple(sorted(bundle, key=_fp))
    issuers = {
        cert: [
            candidate
            for candidate in ordered_bundle
            if _directly_issued_by(cert, candidate)
        ]
        for cert in ordered_bundle
    }
    if require_leaf:
        # ensure we treat self-signed root certificates that have not issued another certificate in this bundle as a leaf
        cert_issuers = {
            issuer
            for subject, candidates in issuers.items()
            for issuer in candidates
            if issuer != subject
        }
        leaves = {cert for cert in ordered_bundle if cert not in cert_issuers}
        if not leaves:
            # This would be unusual, but possible when e.g. two certificates signed each other
            raise ValueError(
                "Certificate bundle did not contain a single leaf certificate"
            )
    else:
        leaves = {}

    def _paths_from(
        cert,
        seen,
    ):
        candidates = [issuer for issuer in issuers[cert] if issuer not in seen]
        if not candidates:
            return [[cert]]
        return [
            [cert, *tail]
            for issuer in candidates
            for tail in _paths_from(issuer, seen | {issuer})
        ]

    paths = [
        path for cert in ordered_bundle for path in _paths_from(cert, frozenset({cert}))
    ]

    # Longest path first; fingerprints provide a stable tie-breaker.
    selected = min(
        paths,
        key=lambda path: (
            -int(path[0] in leaves),
            -len(path),
            tuple(_fp(cert) for cert in path),
        ),
    )
    orphans = [cert for cert in ordered_bundle if cert not in selected]
    if not allow_orphans and orphans:
        raise ValueError(
            "Certificate bundle did not contain a singular chain comprising all certificates"
        )
    return [*selected, *orphans]


def load_cert(cert, passphrase=None, load_chain=False, get_encoding=False):
    """
    Return a certificate instance from

    * a class instance
    * a file path on the local system
    * a string (PEM)
    * bytes (hex, base64, raw)

    Valid encodings are PEM, DER, PKCS7 (as PEM and DER) and PKCS12.
    """
    if isinstance(cert, cx509.Certificate):
        if load_chain:
            return cert, []
        if get_encoding:
            return cert, None, None
        return cert
    cert = load_file_or_bytes(cert)
    # PEM or PKCS7 in PEM
    if PEM_BEGIN in cert:
        pems = split_pems(cert)
        if b"-----BEGIN PKCS7" not in pems[0]:
            try:
                loaded = cx509.load_pem_x509_certificate(pems.pop(0))
                if load_chain or get_encoding:
                    chain = []
                    for pem in pems:
                        try:
                            chain.append(cx509.load_pem_x509_certificate(pem))
                        except ValueError:
                            pass
                if load_chain:
                    return loaded, chain
                if get_encoding:
                    return loaded, "pem", chain, None
                return loaded
            except (ValueError, IndexError) as err:
                raise CertDeserializationError(
                    "Could not load PEM-encoded certificate."
                ) from err
        else:
            try:
                chain = order_certs_naively(pkcs7.load_pem_pkcs7_certificates(pems[0]))
                loaded = chain.pop(0)  # the first cert is sure to be a leaf
                if load_chain:
                    return loaded, chain
                if get_encoding:
                    return loaded, "pkcs7_pem", chain, None
                return loaded
            except ValueError as err:
                raise CertDeserializationError(
                    "Could not load PEM-encoded PKCS#7 blob"
                ) from err
    # DER
    try:
        loaded = cx509.load_der_x509_certificate(cert)
        if get_encoding:
            return loaded, "der", None, None
        if load_chain:
            return loaded, []
        return loaded
    except ValueError:
        pass
    # PKCS12
    try:
        if passphrase is not None and not isinstance(passphrase, bytes):
            passphrase = passphrase.encode()
        # v36+
        loaded = pkcs12.load_pkcs12(cert, passphrase)
        if load_chain or get_encoding:
            chain = [x.certificate for x in loaded.additional_certs]
            if load_chain:
                return loaded.cert.certificate, chain
            if get_encoding:
                return loaded.cert.certificate, "pkcs12", chain, loaded
        return loaded.cert.certificate
    except ValueError as err:
        err_str = str(err)
        if "Bad decrypt" in err_str or "Invalid password" in err_str:
            raise InvalidPassword()
    except AttributeError:
        pass
    # PKCS7
    try:
        # v37+
        bundle = pkcs7.load_der_pkcs7_certificates(cert)
    except ValueError:
        pass
    else:
        try:
            chain = order_certs_naively(bundle)
        except ValueError as err:
            raise CertDeserializationError(str(err)) from err
        loaded = chain.pop(0)  # the first cert is sure to be a leaf
        if load_chain:
            return loaded, chain
        if get_encoding:
            return loaded, "pkcs7_der", chain, None
        return loaded
    # nothing worked
    raise CertDeserializationError(
        "Could not deserialize binary data, neither as DER nor PKCS#7, PKCS#12."
    )


def load_crl(crl, get_encoding=False):
    """
    Return a CRL instance from
    * a class instance
    * a file path on the local system
    * a string (PEM)
    * bytes (hex, base64, raw)

    Valid encodings are PEM and DER.
    """
    if isinstance(crl, cx509.CertificateRevocationList):
        if get_encoding:
            return crl, None, None
        return crl
    crl = load_file_or_bytes(crl)
    if PEM_BEGIN in crl:
        try:
            loaded = cx509.load_pem_x509_crl(crl)
            if get_encoding:
                return loaded, "pem"
            return loaded
        except ValueError as err:
            raise CRLDeserializationError(
                "Could not load PEM-encoded certificate revocation list."
            ) from err
    try:
        loaded = cx509.load_der_x509_crl(crl)
        if get_encoding:
            return loaded, "der"
        return loaded
    except ValueError as err:
        raise CRLDeserializationError(
            "Could not load DER-encoded certificate revocation list."
        ) from err


def load_csr(csr, get_encoding=False):
    """
    Return a CSR instance from
    * a class instance
    * a file path on the local system
    * a string (PEM)
    * bytes (hex, base64, raw)

    Valid encodings are PEM and DER.
    """
    if isinstance(csr, cx509.CertificateSigningRequest):
        if get_encoding:
            return csr, None, None
        return csr
    csr = load_file_or_bytes(csr)
    if PEM_BEGIN in csr:
        try:
            loaded = cx509.load_pem_x509_csr(csr)
            if get_encoding:
                return loaded, "pem"
            return loaded
        except ValueError as err:
            raise CSRDeserializationError(
                "Could not load PEM-encoded certificate signing request."
            ) from err
    try:
        loaded = cx509.load_der_x509_csr(csr)
        if get_encoding:
            return loaded, "der"
        return loaded
    except ValueError as err:
        raise CSRDeserializationError(
            "Could not load DER-encoded certificate signing request."
        ) from err


def verify_signature(cert, pubkey):
    """
    Verifies that the signature on a certificate was made
    by a public key.

    This functionality is currently not exposed by cryptography
    since it does not imply the certificate chain is valid.
    """
    key_type = get_key_type(pubkey)
    if key_type == KEY_TYPE.RSA:
        try:
            # SignatureAlgorithmOID is not present in older versions,
            # otherwise cx509.SignatureAlgorithmOID.RSASSA_PSS could be used
            if cert.signature_algorithm_oid.dotted_string == "1.2.840.113549.1.1.10":
                pubkey.verify(
                    cert.signature,
                    cert.tbs_certificate_bytes,
                    padding.PSS(
                        padding.MGF1(cert.signature_hash_algorithm), padding.PSS.AUTO
                    ),
                    cert.signature_hash_algorithm,
                )
            else:
                pubkey.verify(
                    cert.signature,
                    cert.tbs_certificate_bytes,
                    padding.PKCS1v15(),
                    cert.signature_hash_algorithm,
                )
            return True
        except InvalidSignature:
            return False
    if key_type == KEY_TYPE.EC:
        try:
            pubkey.verify(
                cert.signature,
                cert.tbs_certificate_bytes,
                ec.ECDSA(cert.signature_hash_algorithm),
            )
            return True
        except InvalidSignature:
            return False
    if key_type in [KEY_TYPE.ED25519, KEY_TYPE.ED448]:
        try:
            pubkey.verify(cert.signature, cert.tbs_certificate_bytes)
            return True
        except InvalidSignature:
            return False
    raise SaltInvocationError(
        "Invalid public key type, can only process rsa, ec, ed25519, ed448"
    )


def isfile(path):
    """
    A wrapper around os.path.isfile that ignores ValueError exceptions which
    can be raised if the input to isfile is too long.
    """
    try:
        return os.path.isfile(path)
    except (TypeError, ValueError):
        pass
    return False


def split_pems(pems):
    """
    Returns a list of PEM strings from a possibly concatenated one
    """
    pems = salt.utils.stringutils.to_bytes(pems)
    splits = []
    cur = []

    for line in pems.splitlines(True):
        if not line.strip():
            continue
        if line.startswith(PEM_BEGIN):
            cur = [line]
            continue
        cur.append(line)
        if line.startswith(PEM_END):
            splits.append(b"".join(cur))
            cur = []
    return splits


def load_file_or_bytes(fob):
    """
    Tries to load a reference and return its bytes.
    Can be a file path on the local system, a string and bytes (hex/base64-encoded, raw)
    """
    if isfile(fob):
        with salt.utils.files.fopen(fob, "rb") as f:
            fob = f.read()
    if isinstance(fob, str):
        if fob.startswith("b64:"):
            fob = base64.b64decode(fob[4:])
        elif PEM_BEGIN.decode() in fob:
            fob = fob.encode()
        else:
            try:
                fob = bytes.fromhex(fob)
            except ValueError:
                try:
                    fob = base64.b64decode(fob)
                except ValueError:
                    pass
    if not isinstance(fob, bytes):
        raise SaltInvocationError(
            "Could not load provided source. You need to pass an existing file, "
            "(PEM|hex|base64)-encoded string or raw bytes."
        )
    return fob


def _create_extension(name, val, subject_pubkey=None, ca_crt=None, ca_pub=None):
    if name not in EXTENSION_BUILDERS:
        raise CommandExecutionError(f"Unknown extension {name}")
    return EXTENSION_BUILDERS[name](
        val, subject_pubkey=subject_pubkey, ca_crt=ca_crt, ca_pub=ca_pub
    )


def _create_basic_constraints(val, ca_crt, **_):
    try:
        critical = val.get("critical", False)
    except AttributeError:
        critical = False
    if isinstance(val, str):
        try:
            val, critical = _deserialize_openssl_confstring(val.lower())
            val["ca"] = val["ca"] == "true"
            if "pathlen" in val:
                val["pathlen"] = int(val["pathlen"])
        except (KeyError, ValueError) as err:
            raise SaltInvocationError(
                f"Invalid configuration for basicContraints: {err}"
            ) from err
    if val.get("ca") and ca_crt:
        try:
            ca_bc = ca_crt.extensions.get_extension_for_class(cx509.BasicConstraints)
        except cx509.ExtensionNotFound:
            pass
        else:
            if ca_bc.value.path_length is not None:
                if (
                    val.get("pathlen") is not None
                    and ca_bc.value.path_length <= val["pathlen"]
                ):
                    raise CommandExecutionError(
                        f"Issuing CA certificate has pathlen {ca_bc.value.path_length}, "
                        f"which is less than or equal to requested pathlen of {val['pathlen']}"
                    )
                if val.get("pathlen") is None:
                    val["pathlen"] = ca_bc.value.path_length - 1
    try:
        return (
            cx509.BasicConstraints(val["ca"], val.get("pathlen")),
            critical,
        )
    except KeyError as err:
        raise SaltInvocationError(
            f"Undefined required key for basicConstraints: {err}"
        ) from err
    except (TypeError, ValueError) as err:
        raise SaltInvocationError(err) from err


def _create_key_usage(val, **_):
    critical = "critical" in val
    args = {
        "digital_signature": "digitalSignature" in val,
        "content_commitment": "nonRepudiation" in val,
        "key_encipherment": "keyEncipherment" in val,
        "data_encipherment": "dataEncipherment" in val,
        "key_agreement": "keyAgreement" in val,
        "key_cert_sign": "keyCertSign" in val,
        "crl_sign": "cRLSign" in val,
        "encipher_only": "encipherOnly" in val,
        "decipher_only": "decipherOnly" in val,
    }
    try:
        return cx509.KeyUsage(**args), critical
    except ValueError as err:
        raise SaltInvocationError(err) from err


def _create_extended_key_usage(val, **_):
    critical = "critical" in val
    if isinstance(val, str):
        val, critical = _deserialize_openssl_confstring(val)
        val = list(val)
    if not isinstance(val, list):
        val = [val]
    usages = []
    for usage in val:
        if usage == "critical":
            continue
        usages.append(EXTENDED_KEY_USAGE_OID.get(usage) or _get_oid(str(usage)))
    return cx509.ExtendedKeyUsage(usages), critical


def _create_subject_key_identifier(val, subject_pubkey, **_):
    if "critical" in val:
        raise SaltInvocationError("subjectKeyIdentifier must be marked as non-critical")
    if val == "hash":
        if not subject_pubkey:
            raise RuntimeError(
                "Cannot calculate digest for subjectKeyIdentifier: missing pubkey"
            )
        try:
            return (
                cx509.SubjectKeyIdentifier.from_public_key(subject_pubkey),
                False,
            )
        except AttributeError as err:
            raise RuntimeError(
                "subjectKeyIdentifier: subject_pubkey was not a pubkey"
            ) from err
    if isinstance(val, str):
        try:
            val = bytes.fromhex(val.replace(":", ""))
        except ValueError as err:
            raise SaltInvocationError("Value must be precomputed hash") from err
    if not isinstance(val, bytes):
        raise SaltInvocationError("Value must be a (hex-)digest or pubkey")
    # this must be marked as non-critical
    return cx509.SubjectKeyIdentifier(val), False


def _create_authority_key_identifier(val, ca_crt, ca_pub, **_):
    if "critical" in val:
        raise SaltInvocationError(
            "authorityKeyIdentifier must be marked as non-critical"
        )
    if not (ca_crt or ca_pub):
        raise RuntimeError(
            "Need CA certificate or CA pubkey to calculate authorityKeyIdentifier"
        )
    if isinstance(val, str):
        val, _ = _deserialize_openssl_confstring(val)
    args = {
        "key_identifier": None,
        "authority_cert_issuer": None,
        "authority_cert_serial_number": None,
    }
    if "keyid" in val:
        if ca_crt:
            try:
                args["key_identifier"] = ca_crt.extensions.get_extension_for_class(
                    cx509.SubjectKeyIdentifier
                ).value.digest
            except cx509.ExtensionNotFound:
                args["key_identifier"] = (
                    cx509.AuthorityKeyIdentifier.from_issuer_public_key(
                        ca_crt.public_key()
                    ).key_identifier
                )
            except Exception:  # pylint: disable=broad-except
                pass
        if not args["key_identifier"] and ca_pub:
            # this should happen for self-signed certificates
            try:
                args["key_identifier"] = (
                    cx509.AuthorityKeyIdentifier.from_issuer_public_key(
                        ca_pub
                    ).key_identifier
                )
            except Exception:  # pylint: disable=broad-except
                pass

        if val["keyid"] == "always" and args["key_identifier"] is None:
            raise CommandExecutionError(
                "Could not retrieve authorityKeyIdentifier keyid, but it was set to always"
            )

    if val.get("issuer") == "always" or (
        "issuer" in val and args["key_identifier"] is None
    ):
        try:
            args["authority_cert_issuer"] = [cx509.DirectoryName(ca_crt.issuer)]
            args["authority_cert_serial_number"] = ca_crt.serial_number
        except (AttributeError, ValueError) as err:
            # this will not work for self-signed certificates currently
            args["authority_cert_issuer"] = args["authority_cert_serial_number"] = None
            if val["issuer"] == "always":
                raise CommandExecutionError(
                    "Could not add authority_cert_issuer and "
                    "authority_cert_serial_number, but was set to always."
                ) from err

    if not args:
        raise SaltInvocationError("authorityKeyIdentifier cannot be empty.")
    # this must be marked as non-critical
    return cx509.AuthorityKeyIdentifier(**args), False


def _create_issuer_alt_name(val, ca_crt, **_):
    parsed, critical = _parse_issuer_general_name(val, ca_crt)
    return cx509.IssuerAlternativeName(parsed), critical


def _create_certificate_issuer(val, ca_crt, **_):
    parsed, critical = _parse_issuer_general_name(val, ca_crt)
    return cx509.CertificateIssuer(parsed), critical


def _parse_issuer_general_name(val, ca_crt):
    critical = "critical" in val
    if isinstance(val, list):
        list_ = []
        for x in val:
            if isinstance(x, str) and ":" in x:
                k, v = x.split(":", maxsplit=1)
                list_.append((k.strip(), v.strip()))
            elif isinstance(x, dict):
                list_.extend(list(x.items()))
        # since this is accessed twice, the generator needs to be cast to a tuple
        val = tuple(list_)
    elif isinstance(val, str):
        val, critical = _deserialize_openssl_confstring(val, multiple=True)
        val = tuple(val)
    parsed = []
    if any(x == ("issuer", "copy") for x in val):
        if not ca_crt:
            raise RuntimeError("Need CA certificate to copy to issuerAltName")
        try:
            # eeh, there's no public API to get the list of general names
            parsed.extend(
                copy.deepcopy(
                    ca_crt.extensions.get_extension_for_class(
                        cx509.SubjectAlternativeName
                    )._general_names._general_names
                )
            )
            val = tuple(x for x in val if x[0] != "issuer")
        except cx509.ExtensionNotFound as err:
            raise CommandExecutionError(err) from err
        except AttributeError as err:
            raise CommandExecutionError(
                "It seems your version of cryptography does not have an "
                "internal API that the issuer:copy functionality relies on"
            ) from err
    parsed.extend(parse_general_names(val))
    return parsed, critical


def _create_authority_info_access(val, **_):
    if isinstance(val, str):
        val = (x.strip().split(";") for x in val.split(",") if x.strip() != "critical")
    elif isinstance(val, dict):
        val = ((k, v) for k, v in val.items() if k != "critical")
    elif isinstance(val, list):
        val = ((k, v) for x in val if x != "critical" for k, v in x.items())

    parsed = []
    for oid, general_name in val:
        try:
            oid = ACCESS_OID.get(oid) or _get_oid(str(oid))
        except CommandExecutionError as err:
            raise CommandExecutionError(f"Unknown access OID: {oid}") from err
        general_name = _get_gn(general_name)
        parsed.append(cx509.AccessDescription(oid, general_name))
    return cx509.AuthorityInformationAccess(parsed), False  # always noncritical


def _create_subject_alt_name(val, **_):
    # Note: subjectAltName must be marked as critical if subject is empty.
    # This is not checked.
    critical = "critical" in val
    if isinstance(val, list):
        list_ = []
        for x in val:
            if isinstance(x, str) and ":" in x:
                k, v = x.split(":", maxsplit=1)
                list_.append((k.strip(), v.strip()))
            elif isinstance(x, dict):
                list_.extend(list(x.items()))
        val = tuple(list_)
    elif isinstance(val, str):
        val, critical = _deserialize_openssl_confstring(val, multiple=True)
    parsed = parse_general_names(val)
    return cx509.SubjectAlternativeName(parsed), critical


def _create_crl_distribution_points(val, **_):
    parsed, critical = _parse_distribution_points(val)
    return cx509.CRLDistributionPoints(parsed), critical


def _create_freshest_crl(val, **_):
    parsed, _ = _parse_distribution_points(val)
    return cx509.FreshestCRL(parsed), False  # must be non-critical


def _parse_distribution_points(val):
    critical = "critical" in val
    if isinstance(val, str):
        val, critical = _deserialize_openssl_confstring(val, multiple=True)
    elif isinstance(val, list):
        list_ = []
        for x in val:
            if isinstance(x, str) and ":" in x:
                k, v = x.split(":", maxsplit=1)
                list_.append((k.strip(), v.strip()))
            elif x != "critical":
                list_.append(x)
        val = tuple(list_)
    parsed = []
    for dpoint in val:
        relativename = crlissuer = reasons = None
        if isinstance(dpoint, dict):
            fullname = dpoint.get("fullname")
            relativename = dpoint.get("relativename")
            crlissuer = dpoint.get("crlissuer")
            reasons = dpoint.get("reasons")
            if fullname:
                if not isinstance(fullname, list):
                    fullname = [fullname]
                fullname = (x.split(":", maxsplit=1) for x in fullname)
            if relativename:
                relativename = _get_rdn(relativename)
            if crlissuer:
                if not isinstance(crlissuer, list):
                    crlissuer = [crlissuer]
                crlissuer = parse_general_names(
                    x.split(":", maxsplit=1) for x in crlissuer
                )
            if reasons:
                try:
                    reasons = frozenset(cx509.ReasonFlags(x) for x in reasons)
                except ValueError as err:
                    raise SaltInvocationError(err) from err
        else:
            fullname = (dpoint,)
        if fullname:
            fullname = parse_general_names(fullname)
        try:
            parsed.append(
                cx509.DistributionPoint(
                    full_name=fullname,
                    relative_name=relativename,
                    reasons=reasons,
                    crl_issuer=crlissuer,
                )
            )
        except (ValueError, TypeError) as err:
            raise SaltInvocationError(err) from err
    return parsed, critical


def _create_issuing_distribution_point(val, **_):
    if not isinstance(val, dict):
        raise SaltInvocationError("issuingDistributionPoint must be a dictionary")
    critical = val.get("critical", False)
    onlyuser = val.get("onlyuser", False)
    onlyca = val.get("onlyCA", False)
    onlyaa = val.get("onlyAA", False)
    indirectcrl = val.get("indirectCRL", False)
    fullname = val.get("fullname")
    relativename = val.get("relativename")
    onlysomereasons = val.get("onlysomereasons")
    if fullname:
        if not isinstance(fullname, list):
            fullname = [fullname]
        fullname = (x.split(":", maxsplit=1) for x in fullname)
        fullname = parse_general_names(fullname)
    if relativename:
        relativename = _get_rdn(relativename)
    if onlysomereasons:
        try:
            onlysomereasons = frozenset(cx509.ReasonFlags(x) for x in onlysomereasons)
        except ValueError as err:
            raise SaltInvocationError(err) from err
    try:
        return (
            cx509.IssuingDistributionPoint(
                full_name=fullname,
                relative_name=relativename,
                only_contains_user_certs=onlyuser,
                only_contains_ca_certs=onlyca,
                only_some_reasons=onlysomereasons,
                indirect_crl=indirectcrl,
                only_contains_attribute_certs=onlyaa,
            ),
            critical,
        )
    except (ValueError, TypeError) as err:
        raise SaltInvocationError(err) from err


def _create_certificate_policies(val, **_):
    if isinstance(val, str):
        try:
            critical = val.startswith("critical")
            policy_identifiers = (
                _get_oid(x.strip()) for x in val.split(",") if x.strip() != "critical"
            )
            policy_information = [
                cx509.PolicyInformation(policy_identifier=p, policy_qualifiers=None)
                for p in policy_identifiers
            ]
            return cx509.CertificatePolicies(policy_information), critical
        except CommandExecutionError as err:
            raise SaltInvocationError(
                "certificatePolicies defined as string must be a "
                "comma-separated list of OID."
            ) from err
    critical = val.get("critical", False)
    parsed = []
    for polid, qualifiers in val.items():
        if polid == "critical":
            continue
        parsed_qualifiers = []
        for qual in qualifiers:
            if isinstance(qual, str):
                # pointer to the practice statement published by the certificate authority
                parsed_qualifiers.append(qual)
                continue
            organization = qual.get("organization")
            notice_numbers = qual.get("noticeNumbers")
            text = qual.get("text")
            if notice_numbers:
                try:
                    notice = cx509.NoticeReference(
                        organization=organization, notice_numbers=notice_numbers
                    )
                except TypeError as err:
                    raise CommandExecutionError(err) from err
                parsed_qualifiers.append(
                    cx509.UserNotice(notice_reference=notice, explicit_text=text)
                )
        parsed.append(
            cx509.PolicyInformation(
                policy_identifier=_get_oid(polid),
                policy_qualifiers=parsed_qualifiers,
            )
        )
    return cx509.CertificatePolicies(parsed), critical


def _create_policy_constraints(val, **_):
    critical = "critical" in val
    if isinstance(val, str):
        val, critical = _deserialize_openssl_confstring(val)
    args = {
        "require_explicit_policy": (
            int(val["requireExplicitPolicy"])
            if "requireExplicitPolicy" in val
            else None
        ),
        "inhibit_policy_mapping": (
            int(val["inhibitPolicyMapping"]) if "inhibitPolicyMapping" in val else None
        ),
    }
    try:
        # not sure why pylint complains about this line having kwargs from keyUsage
        # pylint: disable=unexpected-keyword-arg
        return cx509.PolicyConstraints(**args), critical
    except (TypeError, ValueError) as err:
        raise SaltInvocationError(err) from err


def _create_inhibit_any_policy(val, **_):
    critical = "critical" in val if not isinstance(val, int) else False
    if isinstance(val, str):
        val, critical = _deserialize_openssl_confstring(val)
        val = int(next(iter(val)))
    try:
        return (
            cx509.InhibitAnyPolicy(val if isinstance(val, int) else val["value"]),
            critical,
        )
    except KeyError as err:
        raise SaltInvocationError(
            f"Undefined required key for inhibitAnyPolicy: {err}"
        ) from err
    except (TypeError, ValueError) as err:
        raise SaltInvocationError(err) from err


def _create_name_constraints(val, **_):
    critical = "critical" in val
    if isinstance(val, dict):
        parsed = {}
        for scope, constraints in val.items():
            if scope not in ("permitted", "excluded"):
                continue
            list_ = []
            for x in constraints:
                if isinstance(x, str) and ":" in x:
                    k, v = x.split(":", maxsplit=1)
                    list_.append((k.strip(), v.strip()))
                elif x != "critical":
                    list_.append(x)
            parsed[scope] = tuple(list_)
        val = parsed
    elif isinstance(val, str):
        items = tuple(x.strip().split(";") for x in val.split(","))
        val = {
            "permitted": [
                x[1].split(":", maxsplit=1) for x in items if x[0] == "permitted"
            ],
            "excluded": [
                x[1].split(":", maxsplit=1) for x in items if x[0] == "excluded"
            ],
        }
    args = {
        "permitted_subtrees": (
            parse_general_names(val["permitted"], name_constraints=True)
            if "permitted" in val
            else None
        ),
        "excluded_subtrees": (
            parse_general_names(val["excluded"], name_constraints=True)
            if "excluded" in val
            else None
        ),
    }
    if not any(args.values()):
        raise SaltInvocationError("nameConstraints needs at least one definition")
    return cx509.NameConstraints(**args), critical


def _create_no_check(val, **_):
    return cx509.OCSPNoCheck(), "critical" in str(val)


def _create_tlsfeature(val, **_):
    if isinstance(val, str):
        val = [x.strip() for x in val.split(",")]
    critical = "critical" in val
    try:
        types = [getattr(cx509.TLSFeatureType, x) for x in val if x != "critical"]
    except ValueError as err:
        raise SaltInvocationError(err) from err
    return cx509.TLSFeature(types), critical


def _create_ns_comment(val, **_):
    raise SaltInvocationError("nsComment is currently not implemented.")


def _create_ns_cert_type(val, **_):
    raise SaltInvocationError("nsCertType is currently not implemented.")


def _create_crl_number(val, **_):
    try:
        return cx509.CRLNumber(int(val)), False
    except ValueError as err:
        raise SaltInvocationError(
            "cRLNumber must be an integer and must be marked as non-critical"
        ) from err


def _create_delta_crl_indicator(val, **_):
    critical = "critical" in str(val)
    val = re.findall(r"[\d]+", str(val))
    if len(val) != 1:
        raise SaltInvocationError(
            "deltaCRLIndicator must contain a single integer pointing to a cRLNumber"
        )
    return cx509.DeltaCRLIndicator(int(val[0])), critical


def _create_crl_reason(val, **_):
    critical = False
    if isinstance(val, str):
        val, critical = _deserialize_openssl_confstring(val)
    else:
        if "critical" in val:
            critical = True
            val = [x for x in val if x != "critical"]

    try:
        return cx509.CRLReason(cx509.ReasonFlags(next(iter(val)))), critical
    except ValueError as err:
        raise SaltInvocationError(str(err)) from err


def _create_invalidity_date(val, **_):
    if not isinstance(val, str):
        raise SaltInvocationError("invalidityDate must be a string")
    critical = val.startswith("critical")
    if critical:
        val = val.split(" ", maxsplit=1)[1]
    try:
        # InvalidityDate deals in naive datetime objects only currently
        return (
            cx509.InvalidityDate(datetime.strptime(val, TIME_FMT)),
            critical,
        )
    except ValueError as err:
        raise SaltInvocationError(str(err)) from err


# Map canonical name of extension to builder function.
# This is done globally to avoid having to instantiate this
# over and over.
EXTENSION_BUILDERS = immutabletypes.freeze(
    {
        "basicConstraints": _create_basic_constraints,
        "keyUsage": _create_key_usage,
        "extendedKeyUsage": _create_extended_key_usage,
        "subjectKeyIdentifier": _create_subject_key_identifier,
        "authorityKeyIdentifier": _create_authority_key_identifier,
        "issuerAltName": _create_issuer_alt_name,
        "certificateIssuer": _create_certificate_issuer,
        "authorityInfoAccess": _create_authority_info_access,
        "subjectAltName": _create_subject_alt_name,
        "crlDistributionPoints": _create_crl_distribution_points,
        "freshestCRL": _create_freshest_crl,
        "issuingDistributionPoint": _create_issuing_distribution_point,
        "certificatePolicies": _create_certificate_policies,
        "policyConstraints": _create_policy_constraints,
        "inhibitAnyPolicy": _create_inhibit_any_policy,
        "nameConstraints": _create_name_constraints,
        "noCheck": _create_no_check,
        "tlsfeature": _create_tlsfeature,
        "nsComment": _create_ns_comment,
        "nsCertType": _create_ns_cert_type,
        "cRLNumber": _create_crl_number,
        "deltaCRLIndicator": _create_delta_crl_indicator,
        "CRLReason": _create_crl_reason,
        "invalidityDate": _create_invalidity_date,
    }
)


def _deserialize_openssl_confstring(conf, multiple=False):
    critical = conf.startswith("critical")
    if critical:
        conf = conf[8:].strip(",").strip()
    items = (x.strip() for x in conf.split(","))
    if multiple:
        return (
            (k.strip(), v.strip())
            for k, v in (
                x.split(":", maxsplit=1) if ":" in x else (x, "__present__")
                for x in items
            )
        ), critical
    return {
        k.strip(): v.strip()
        for k, v in (
            x.split(":", maxsplit=1) if ":" in x else (x, "__present__") for x in items
        )
    }, critical


def _parse_other_name(value):
    """
    Parse otherName definition. Accepted formats:

    OpenSSL-style string
        e.g. ``1.2.3.4;UTF8:foobar``. Can only map to UTF8STRING, other ASN1 types raise an exception.

    Dictionary
        ``{oid: 1.2.3.4, value: foobar}``: ``value`` is passed into the encoder,
        meaning other simple types (in addition to UTF8, like BOOLEAN) are supported, even from SLS files.
        In theory, more complex types can be passed in programmatically from Python.

        ``{oid: 1.2.3.4, der: "hex:deadbeef"}``: ``der`` can be an arbitrary DER blob.
        It needs to be a hex/base64-encoded string with ``hex:``/``b64:`` prefix.
        Raw Python bytes are passed through.
    """
    if isinstance(value, str):
        try:
            oid_text, asn_expr = value.split(";", maxsplit=1)
        except ValueError as err:
            raise SaltInvocationError(
                "`othername` string definition needs semicolon (;) between OID and "
                "value: othername:1.2.3.4;UTF8:value"
            ) from err
        asn_expr = asn_expr.removeprefix(
            "FORMAT:UTF8,"
        )  # Compatibility with OpenSSL's documented SmtpUTF8Mailbox spelling
        try:
            asn_typ, asn_val = asn_expr.split(":", maxsplit=1)
        except ValueError as err:
            raise SaltInvocationError(
                "`othername` string definition needs colon (:) between value type and "
                "value: othername:1.2.3.4;UTF8:value"
            ) from err
        if asn_typ.upper() not in {"UTF8", "UTF8STRING"}:
            raise SaltInvocationError(
                f"Unsupported otherName ASN.1 type {asn_typ!r}; only UTF8STRING is supported"
            )
        oid = _get_oid(oid_text)
        try:
            encoded = asn1.encode_der(asn_val)
        except ValueError as err:
            raise SaltInvocationError(
                f"Failed parsing OpenSSL otherName value {value!r}"
            ) from err
        return cx509.OtherName(oid, encoded)

    if not isinstance(value, dict):
        raise SaltInvocationError(
            f"Invalid otherName definition, dict or string required, got {value!r}"
        )
    if "oid" not in value:
        raise SaltInvocationError("Invalid otherName definition, missing `oid` key")
    oid = _get_oid(value["oid"])

    if "der" in value:
        if isinstance(value["der"], bytes):
            encoded = value["der"]
        elif value["der"].startswith("hex:"):
            try:
                encoded = bytes.fromhex(value["der"].removeprefix("hex:"))
            except ValueError as err:
                raise SaltInvocationError(
                    "Failed to parse otherName `der` input as hex"
                ) from err
        elif value["der"].startswith("b64:"):
            try:
                encoded = base64.b64decode(value["der"].removeprefix("b64:"))
            except ValueError as err:
                raise SaltInvocationError(
                    "Failed to parse otherName `der` input as base64"
                ) from err
        else:
            raise SaltInvocationError(
                "Failed to parse otherName `der` input, needs `hex:` or `b64:` prefix"
            )
        return cx509.OtherName(oid, encoded)
    if "value" in value:
        # Support basic types by passing them through
        to_encode = value["value"]
        if to_encode is None:
            to_encode = asn1.Null()
        try:
            encoded = asn1.encode_der(to_encode)
        except ValueError as err:
            raise SaltInvocationError(
                f"Failed to encode otherName value {value['value']!r} to ASN1"
            ) from err
        return cx509.OtherName(oid, encoded)
    raise SaltInvocationError(
        "Invalid otherName definition, missing `value` or `der` key"
    )


def _validate_dns_label(label, *, allow_wildcard=False):
    """
    Reject strings that are not valid ASCII DNS labels.
    """
    if not label:
        raise CommandExecutionError("Empty Label")
    label.encode(encoding="ascii")  # ensure only ASCII chars
    allowed = r"A-Za-z\d\-"
    if allow_wildcard:
        allowed += r"\*"
    invalid = re.search(f"[^{allowed}]", label)
    if invalid is not None:
        raise CommandExecutionError(
            f"Codepoint U+00{ord(invalid.group()):02X} at position {invalid.end()} of '{label}' not allowed"
        )
    if label[0] == "-" or label[-1] == "-":
        raise CommandExecutionError("Label must not start or end with a hyphen")
    if len(label.replace("*", "") if allow_wildcard else label) > 63:
        raise CommandExecutionError("Label too long")


def _validate_dns_name(dns_name, *, allow_wildcard=False, allow_trailing_dot=False):
    """
    Reject strings that are not valid ASCII DNS domains.
    """
    if not dns_name:
        raise CommandExecutionError("Empty domain")
    dns_name.encode(
        encoding="ascii"
    )  # ensure only ASCII chars, including label separators
    labels = dns_name.split(".")
    if allow_trailing_dot and not labels[-1]:
        labels.pop()
    for label in labels:
        _validate_dns_label(label, allow_wildcard=allow_wildcard)


def idna_encode(domain, *, allow_leading_dot=False, allow_trailing_dot=False):
    """
    Encode a domain that might contain unicode characters into punycode, as per IDNA.

    domain
        Value to encode.

    allow_leading_dot
        Allow DNSNames like ``.example.com``, as seen e.g. in nameConstraints.
    """
    # A leading dot is allowed in some values (nameConstraints).
    # idna complains about it not being a valid domain name
    try:
        leading_dot = domain[0] in ("\u002e", "\u3002", "\uff0e", "\uff61")
        trailing_dot = domain[-1] in ("\u002e", "\u3002", "\uff0e", "\uff61")
    except (KeyError, TypeError):
        raise CommandExecutionError(
            f"Expected string value, got {type(domain).__name__}: `{domain!r}`"
        )
    except IndexError:
        raise CommandExecutionError("Empty domain")
    if trailing_dot and not allow_trailing_dot:
        raise CommandExecutionError("Trailing dots are not allowed in this context")
    if leading_dot:
        if not allow_leading_dot:
            raise CommandExecutionError("Leading dots are not allowed in this context")
        domain = domain[1:]
    if "*" in domain:
        raise CommandExecutionError("Wildcards are not allowed in this context")
    if HAS_IDNA:
        try:
            ret = idna.encode(domain).decode()
        except idna.IDNAError as err:
            raise CommandExecutionError(str(err)) from err
    else:
        try:
            _validate_dns_name(domain, allow_trailing_dot=allow_trailing_dot)
        except UnicodeEncodeError as err:
            raise CommandExecutionError(
                "Cannot encode non-ASCII strings to internationalized domain "
                "name format, missing library: idna"
            ) from err
        if len(domain) > (254 if trailing_dot else 253):
            raise CommandExecutionError("Domain too long")
        ret = domain
    if leading_dot:
        return f".{ret}"
    return ret


def idna_encode_with_wildcard(domain: str, *, allow_trailing_dot=False):
    """
    Encode a domain that might contain unicode characters into punycode, as per IDNA.
    Unlike ``idna_encode``, labels that contain a wildcard character are allowed.
    These labels must consist entirely of valid ASCII DNS-label characters;
    any internationalized portions must already be IDNA-encoded.

    domain
        Value to encode.
    """
    if not domain:
        raise CommandExecutionError("Empty domain")
    try:
        labels = re.split("[\u002e\u3002\uff0e\uff61]", domain)
    except TypeError:
        raise SaltInvocationError(
            f"Expected string value, got {type(domain).__name__}: `{domain!r}`"
        )
    if trailing_dot := not labels[-1]:
        if not allow_trailing_dot:
            raise CommandExecutionError("Trailing dots are not allowed in this context")
        labels.pop()
    if labels[0] == "":
        raise CommandExecutionError("Leading dots are not allowed in this context")
    encoded = []
    for label in labels:
        if "*" in label:
            try:
                _validate_dns_label(label, allow_wildcard=True)
            except UnicodeEncodeError as err:
                raise CommandExecutionError(
                    "Label with wildcard must contain ASCII characters only; "
                    "internationalized portions must already be IDNA-encoded"
                ) from err
            encoded.append(label)
        elif not HAS_IDNA:
            try:
                _validate_dns_label(label)
            except UnicodeEncodeError as err:
                raise CommandExecutionError(
                    "Cannot encode non-ASCII strings to internationalized domain "
                    "name format, missing library: idna"
                ) from err
            encoded.append(label)
        else:
            try:
                alabel = idna.alabel(label)
            except idna.IDNAError as err:
                raise CommandExecutionError(str(err)) from err
            encoded.append(alabel.decode())
    if trailing_dot:
        encoded.append("")
    ret = ".".join(encoded)
    if len(ret.replace("*", "")) > (254 if trailing_dot else 253):
        raise CommandExecutionError("Domain too long")
    return ret


def parse_general_names(val, *, name_constraints=False):
    """
    Hydrate a list of General Name definition tuples of ``(type, value)`` into
    cryptography objects.

    val
        List of 2-tuples. Each tuple is of the form ``(<type>, <value>)``, where ``<type>``
        is one of ``email``, ``uri``, ``dns``, ``rid``, ``ip``, ``dirname`` or ``othername``.
        ``<type>`` is case-insensitive.

    name_constraints
        Indicate that the list of GNs is intended for the ``nameConstraints`` extension, which has
        specific requirements (e.g. IP networks instead of addresses, allows leading dot in
        domain names, but no wildcards). Defaults to false.
    """
    valid_types = {
        "email": cx509.general_name.RFC822Name,
        "uri": cx509.general_name.UniformResourceIdentifier,
        "dns": cx509.general_name.DNSName,
        "rid": cx509.general_name.RegisteredID,
        "ip": cx509.general_name.IPAddress,
        "dirname": cx509.general_name.DirectoryName,
        "othername": _parse_other_name,
    }

    def _encode_domain(
        domain, wildcards=False, nc_leading_dot=True, trailing_dot=False
    ):
        if name_constraints:
            return idna_encode(domain, allow_leading_dot=nc_leading_dot)
        if wildcards and "*" in str(domain):
            return idna_encode_with_wildcard(domain, allow_trailing_dot=trailing_dot)
        return idna_encode(domain, allow_trailing_dot=trailing_dot)

    parsed = []
    for typ, v in val:
        typ = typ.lower()
        if typ == "dirname":
            res = _get_dn(v)
        elif typ == "rid":
            res = _get_oid(v)
        elif typ == "ip":
            try:
                if name_constraints:
                    res = ipaddress.ip_network(v)
                else:
                    res = ipaddress.ip_address(v)
            except ValueError as err:
                raise CommandExecutionError(
                    f"Provided value {v!r} does not seem to be an IPv4/IPv6 {'network range' if name_constraints else 'address'}."
                ) from err
        elif typ == "email":
            try:
                has_user = "@" in v
            except TypeError as err:
                raise CommandExecutionError(
                    f"Expected string value, got {type(v).__name__}: `{v!r}`"
                ) from err
            if has_user:
                user, domain = v.rsplit("@", maxsplit=1)
                try:
                    user.encode("ascii")
                except UnicodeEncodeError as err:
                    raise CommandExecutionError(
                        "Email address username must not contain non-ASCII chars, use SmtpUTF8Mailbox otherName instead"
                    ) from err
            elif not name_constraints:
                raise CommandExecutionError(f"Not a valid email in this context: {v}")
            else:
                user, domain = None, v
            domain = _encode_domain(domain, nc_leading_dot=user is None)
            res = domain if user is None else f"{user}@{domain}"
        elif typ == "uri":
            if (
                name_constraints
            ):  # A URI in nameConstraints is parsed exactly like a DNSName
                try:
                    res = _encode_domain(v)
                except CommandExecutionError as err:
                    # Friendlier error message for https://foo.bar etc.
                    # Cannot check this before because .foo.bar - allowed in NameConstraints - is parsed as a path, not a netloc
                    try:
                        url = urlsplit(v)
                    except (AttributeError, TypeError, ValueError):
                        raise CommandExecutionError(
                            f"Expected string value, got {type(v).__name__}: `{v!r}`"
                        ) from err
                    if url.scheme:
                        raise CommandExecutionError(
                            f"NameConstraints URI should be the same format as DNS, not a full URI. Got: {v}"
                        ) from err
                    if "*" in v:
                        raise CommandExecutionError(
                            "Wildcards are not allowed in this context"
                        )
                    raise
            else:
                try:
                    if re.search(r"%(?![0-9A-Fa-f]{2})", v):
                        raise CommandExecutionError(
                            f"Invalid percent-encoding in URI: {v}"
                        )
                except TypeError as err:
                    raise CommandExecutionError(
                        f"Expected string value, got {type(v).__name__}: `{v!r}`"
                    ) from err
                url = urlsplit(v)
                if not url.scheme:
                    if v.startswith("."):
                        raise CommandExecutionError(
                            "Leading dots are not allowed in this context"
                        )
                    raise CommandExecutionError("URI must contain a scheme")

                netloc = url.netloc
                if hostname := url.hostname:
                    try:
                        ip = ipaddress.ip_address(hostname)
                    except ValueError:
                        host = _encode_domain(
                            hostname, wildcards=True, trailing_dot=True
                        )
                    else:
                        host = f"[{ip}]" if ip.version == 6 else str(ip)
                    try:
                        port = url.port
                    except ValueError as err:
                        raise CommandExecutionError(str(err)) from err
                    if port is not None:
                        host = f"{host}:{port}"

                    if url.username is not None:
                        userinfo = url.username
                        if url.password is not None:
                            userinfo += f":{url.password}"
                        userinfo = quote(userinfo, safe="!$&'()*+,;=:%")
                        netloc = f"{userinfo}@{host}"
                    else:
                        netloc = host

                # Also convert IRI to URI. % is safe since we already validated all of them, just pass through
                safe_chars = "/:@!$&'()*+,;=%"
                res = urlunsplit(
                    (
                        url.scheme,
                        netloc,
                        quote(url.path, safe=safe_chars),
                        quote(url.query, safe=safe_chars + "?"),
                        quote(url.fragment, safe=safe_chars + "?"),
                    )
                )
        elif typ == "dns":
            res = _encode_domain(v, wildcards=True)
        else:
            res = v
        if typ in valid_types:
            try:
                parsed.append(valid_types[typ](res))
                continue
            except (ValueError, TypeError) as err:
                raise CommandExecutionError(err) from err
        raise CommandExecutionError(f"GeneralName type {typ} is invalid")
    return parsed


def _get_oid(oid):
    if not str(oid).startswith(("0", "1", "2")) or str(oid).strip("0123456789."):
        raise CommandExecutionError(f"Invalid oid: {oid}")
    return cx509.ObjectIdentifier(oid)


def _get_rdn(rdn):
    try:
        rdns = cx509.Name.from_rfc4514_string(rdn).rdns
        if len(rdns) > 1:
            raise SaltInvocationError(
                "Specified string is not a Relative Distinguished Name, "
                "but a Distinguished Name."
            )
        return rdns[0]
    except ValueError as err:
        raise CommandExecutionError(f"Failed parsing rdn string: {rdn}") from err
    except AttributeError as err:
        raise CommandExecutionError(
            "At least cryptography v37 is required for parsing RFC4514 strings."
        ) from err


def _get_gn(gn):
    return parse_general_names((gn.split(":", maxsplit=1),))[0]


def _get_serial_number(sn=None):
    if sn is None:
        return cx509.random_serial_number()
    if isinstance(sn, int):
        return sn
    try:
        sn = bytes.fromhex(sn.replace(":", ""))
    except (AttributeError, TypeError, ValueError):
        pass
    if isinstance(sn, bytes):
        return int.from_bytes(sn, "big")
    raise CommandExecutionError(f"Could not parse serial number {sn}")


def _get_dn(dn):
    if isinstance(dn, str):
        try:
            parsed = cx509.Name.from_rfc4514_string(dn)
            if CRYPTOGRAPHY_VERSION[0] == 37:
                return cx509.Name(parsed.rdns[::-1])
            return parsed
        except ValueError as err:
            raise CommandExecutionError(
                "Failed parsing rfc4514 dirName string"
            ) from err
        except AttributeError as err:
            raise CommandExecutionError(
                "At least cryptography v37 is required for parsing RFC4514 strings."
            ) from err
    elif isinstance(dn, list):
        return cx509.Name([_get_rdn(x) for x in dn])
    elif isinstance(dn, dict):
        parsed = []
        for name, oid in NAME_ATTRS_OID.items():
            if name in dn:
                parsed.append(cx509.NameAttribute(oid, dn[name]))
        return cx509.Name(parsed)

    raise SaltInvocationError("Need string, list or dict to parse distinguished names")


def pretty_hex(hex_str):
    """
    Nicely formats hex strings
    """
    if isinstance(hex_str, bytes):
        hex_str = hex_str.hex()
    if len(hex_str) % 2 != 0:
        hex_str = "0" + hex_str
    return ":".join([hex_str[i : i + 2] for i in range(0, len(hex_str), 2)]).upper()


def dec2hex(decval):
    """
    Converts decimal values to nicely formatted hex strings
    """
    return pretty_hex(f"{decval:X}")


def render_gn(gn):
    """
    Returns a valid OpenSSL string for a GeneralName instance
    """
    if isinstance(gn, cx509.DNSName):
        return f"DNS:{gn.value}"
    if isinstance(gn, cx509.DirectoryName):
        return f"dirName:{gn.value.rfc4514_string()}"
    if isinstance(gn, cx509.IPAddress):
        return f"IP:{gn.value.exploded}"
    if isinstance(gn, cx509.RFC822Name):
        return f"email:{gn.value}"
    if isinstance(gn, cx509.RegisteredID):
        return f"RID:{gn.value.dotted_string}"
    if isinstance(gn, cx509.UniformResourceIdentifier):
        return f"URI:{gn.value}"
    if isinstance(gn, cx509.OtherName):
        try:
            val = "UTF8:" + asn1.decode_der(str, gn.value)
        except ValueError:
            val = f"<hex:{gn.value.hex()}>"
        return f"otherName:{gn.type_id.dotted_string};{val}"
    return str(gn)


def render_extension(ext):
    """
    Render an Extension instance to a dict for informational purposes
    """
    ret = {"critical": ext.critical}
    typ = type(ext.value)
    if typ in EXTENSION_RENDERERS:
        ret.update(EXTENSION_RENDERERS[typ](ext))
    else:
        ret["value"] = str(ext.value)
    return ret


def _render_basic_constraints(ext):
    return {"ca": ext.value.ca, "pathlen": ext.value.path_length}


def _render_key_usage(ext):
    return {
        "cRLSign": ext.value.crl_sign,
        "dataEncipherment": ext.value.data_encipherment,
        "decipherOnly": ext.value.decipher_only if ext.value.key_agreement else False,
        "digitalSignature": ext.value.digital_signature,
        "encipherOnly": ext.value.encipher_only if ext.value.key_agreement else False,
        "keyAgreement": ext.value.key_agreement,
        "keyCertSign": ext.value.key_cert_sign,
        "keyEncipherment": ext.value.key_encipherment,
        "nonRepudiation": ext.value.content_commitment,
    }


def _render_extended_key_usage(ext):
    # this does not account for unnamed OIDs
    try:
        usages = [
            x._name if x._name != "Unknown OID" else x.dotted_string
            for x in ext.value._usages or []
        ]
    except AttributeError:
        # best effort in case x._name becomes undefined at some point
        usages = re.findall(
            r"\<ObjectIdentifier\(oid=[\d\.]+, name=([\w]+)\)\>", str(ext.value)
        )
        usages = [x[1] if "Unknown OID" != x[1] else x[0] for x in usages]
    return {"value": usages}


def _render_subject_key_identifier(ext):
    return {"value": pretty_hex(ext.value.digest)}


def _render_authority_key_identifier(ext):
    return {
        "keyid": (
            pretty_hex(ext.value.key_identifier) if ext.value.key_identifier else None
        ),
        "issuer": [render_gn(x) for x in ext.value.authority_cert_issuer or []] or None,
        "issuer_sn": (
            dec2hex(ext.value.authority_cert_serial_number)
            if ext.value.authority_cert_serial_number
            else None
        ),
    }


def _render_general_names(ext):
    try:
        rendered = [render_gn(x) for x in ext.value._general_names._general_names or []]
    except AttributeError:
        # best effort in case ext.value._general_names._general_names
        # becomes undefined at some point
        prefixes = {
            "DNSName": "dns",
            "RFC822Name": "email",
            "UniformResourceIdentifier": "url",
            "RegisteredID": "rid",
            "IPAddress": "ip",
            "DirectoryName": "dirName",
        }
        rendered = [
            f"{prefixes[typ]}:{gn}"
            for typ, gn in re.findall(
                rf"\<({'|'.join(prefixes)})\(value='([^']+)'\)\>",
                str(ext.value),
            )
        ]
    return {"value": rendered}


def _render_authority_info_access(ext):
    rendered = []
    try:
        for description in ext.value._descriptions:
            rendered.append(
                {
                    (
                        description.access_method._name
                        if description.access_method._name != "Unknown OID"
                        else description.access_method.dotted_string
                    ): render_gn(description.access_location.value)
                }
            )
    except AttributeError:
        rendered = str(ext.value)
    return {"value": rendered}


def _render_distribution_points(ext):
    dpoints = []
    try:
        for dpoint in ext.value._distribution_points:
            dpoints.append(
                {
                    "crlissuer": [render_gn(x) for x in dpoint.crl_issuer or []],
                    "fullname": [render_gn(x) for x in dpoint.full_name or []],
                    "reasons": list(sorted(x.value for x in dpoint.reasons or [])),
                    "relativename": (
                        dpoint.relative_name.rfc4514_string()
                        if dpoint.relative_name
                        else None
                    ),
                }
            )
    except AttributeError:
        dpoints = str(ext.value)
    return {"value": dpoints}


def _render_issuing_distribution_point(ext):
    return {
        "fullname": [render_gn(x) for x in ext.value.full_name or []],
        "onlysomereasons": list(
            sorted(x.value for x in ext.value.only_some_reasons or [])
        ),
        "relativename": (
            ext.value.relative_name.rfc4514_string()
            if ext.value.relative_name
            else None
        ),
        "onlyuser": ext.value.only_contains_user_certs,
        "onlyCA": ext.value.only_contains_ca_certs,
        "onlyAA": ext.value.only_contains_attribute_certs,
        "indirectCRL": ext.value.indirect_crl,
    }


def _render_certificate_policies(ext):
    policies = []
    try:
        for policy in ext.value._policies:
            polid = policy.policy_identifier._name
            if polid == "Unknown OID":
                polid = policy.policy_identifier.dotted_string
            qualifiers = []
            for notice in policy.policy_qualifiers or []:
                if isinstance(notice, str):
                    qualifiers.append({"practice_statement": notice})
                    continue
                organization = notice_numbers = None
                if notice.notice_reference:
                    organization = notice.notice_reference.organization
                    notice_numbers = notice.notice_reference.notice_numbers
                qualifiers.append(
                    {
                        "organization": organization,
                        "notice_numbers": notice_numbers,
                        "explicit_text": notice.explicit_text,
                    }
                )
            policies.append({polid: qualifiers})
    except AttributeError:
        policies = str(ext.value)
    return {"value": policies}


def _render_policy_constraints(ext):
    return {
        "inhibitPolicyMapping": ext.value.inhibit_policy_mapping,
        "requireExplicitPolicy": ext.value.require_explicit_policy,
    }


def _render_inhibit_any_policy(ext):
    return {"value": ext.value.skip_certs}


def _render_name_constraints(ext):
    return {
        "excluded": [render_gn(x) for x in ext.value.excluded_subtrees or []],
        "permitted": [render_gn(x) for x in ext.value.permitted_subtrees or []],
    }


def _render_no_check(ext):
    return {"value": True}


def _render_tlsfeature(ext):
    try:
        features = [x.name for x in ext.value._features]
    except AttributeError:
        features = []
        if "status_request" in str(ext.value):
            features.append("status_request")
        if "status_request_v2" in str(ext.value):
            features.append("status_request_v2")
    return {"value": features}


def _render_crl_number(ext):
    return {"value": ext.value.crl_number}


def _render_crl_reason(ext):
    return {"value": ext.value.reason.value}


def _render_invalidity_date(ext):
    return {"value": ext.value.invalidity_date.strftime(TIME_FMT)}


def _strptime(val, param):
    if val is None:
        return val
    try:
        return datetime.strptime(val, TIME_FMT).replace(tzinfo=timezone.utc)
    except ValueError as err:
        raise SaltInvocationError(
            f"Invalid date format in param `{param}`: {err}"
        ) from err


EXTENSION_RENDERERS = immutabletypes.freeze(
    {
        cx509.BasicConstraints: _render_basic_constraints,
        cx509.KeyUsage: _render_key_usage,
        cx509.ExtendedKeyUsage: _render_extended_key_usage,
        cx509.SubjectKeyIdentifier: _render_subject_key_identifier,
        cx509.AuthorityKeyIdentifier: _render_authority_key_identifier,
        cx509.IssuerAlternativeName: _render_general_names,
        cx509.CertificateIssuer: _render_general_names,
        cx509.AuthorityInformationAccess: _render_authority_info_access,
        cx509.SubjectAlternativeName: _render_general_names,
        cx509.CRLDistributionPoints: _render_distribution_points,
        cx509.FreshestCRL: _render_distribution_points,
        cx509.IssuingDistributionPoint: _render_issuing_distribution_point,
        cx509.CertificatePolicies: _render_certificate_policies,
        cx509.PolicyConstraints: _render_policy_constraints,
        cx509.InhibitAnyPolicy: _render_inhibit_any_policy,
        cx509.NameConstraints: _render_name_constraints,
        cx509.OCSPNoCheck: _render_no_check,
        cx509.TLSFeature: _render_tlsfeature,
        cx509.CRLNumber: _render_crl_number,
        cx509.DeltaCRLIndicator: _render_crl_number,
        cx509.CRLReason: _render_crl_reason,
        cx509.InvalidityDate: _render_invalidity_date,
    }
)
