#  2018-12-19 Paul Lettich <paul.lettich@netknights.it>
#             Change public functions to accept and return unicode
#  2017-11-24 Cornelius Kölbel <cornelius.koelbel@netknights.it>
#             Use HSM for iv in aes_encrypt
#  2017-10-17 Cornelius Kölbel <cornelius.koelbel@netknights.it>
#             Add encryption/decryption for PSKC containers.
#  2016-04-08 Cornelius Kölbel <cornelius@privacyidea.org>
#             Avoid consecutive if statements
#
#  privacyIDEA is a fork of LinOTP
#  May 08, 2014 Cornelius Kölbel
#  License:  AGPLv3
#  contact:  http://www.privacyidea.org
#
#  Copyright (C) 2010 - 2014 LSE Leading Security Experts GmbH
#  License:  AGPLv3
#  contact:  http://www.linotp.org
#            http://www.lsexperts.de
#            linotp@lsexperts.de
#
# This code is free software; you can redistribute it and/or
# modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
# License as published by the Free Software Foundation; either
# version 3 of the License, or any later version.
#
# This code is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU AFFERO GENERAL PUBLIC LICENSE for more details.
#
# You should have received a copy of the GNU Affero General Public
# License along with this program.  If not, see <http://www.gnu.org/licenses/>.
#
"""
contains all crypto functions.
Security module functions are contained under lib/security/

The encrypt/decrypt functions for PINs and passwords accept bytes as well
as unicode strings. They always return a hexlified unicode string.
The functions which encrypt/decrypt arbitrary data return bytes and let the
calling function handle the data.

This lib.crypto is tested in tests/test_lib_crypto.py
"""
import hmac
import importlib
import logging
from dataclasses import dataclass
from hashlib import sha256
import secrets
import random
import string
import binascii
import ctypes
import base64
import traceback
from typing import Union

from cryptography.hazmat.primitives._serialization import NoEncryption
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicKey, EllipticCurvePrivateKey
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
from cryptography.hazmat.primitives.hashes import HashAlgorithm
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from passlib.context import CryptContext
from privacyidea.lib.log import log_with
from privacyidea.lib.error import HSMException, ParameterError
from privacyidea.lib.framework import (get_app_local_store, get_app_config_value,
                                       get_app_config)
from privacyidea.lib.utils import (to_unicode, to_bytes, hexlify_and_unicode,
                                   b64encode_and_unicode)

from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric import rsa, ec
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding as asym_padding


def safe_compare(a, b):
    return hmac.compare_digest(to_bytes(a), to_bytes(b))


ROUNDS = 9

# The CryptContext makes it easier to work with multiple password hash algorithms:
# The first algorithm in the list is the default algorithm used for hashing.
# When verifying a password hash, all algorithms in the context are checked
# until one succeeds (or all fail).

DEFAULT_HASH_ALGO_LIST = ['argon2', 'pbkdf2_sha512']
DEFAULT_HASH_ALGO_PARAMS = {'argon2__rounds': ROUNDS}

FAILED_TO_DECRYPT_PASSWORD = "FAILED TO DECRYPT PASSWORD!"  # nosec B105 # placeholder in case of error

log = logging.getLogger(__name__)


@dataclass
class KeyPair:
    public_key = None
    private_key = None

    def __init__(self, public_key=None, private_key=None):
        self.public_key = public_key
        self.private_key = private_key


class SecretObj(object):
    def __init__(self, val, iv, preserve=True):
        self.val = val
        self.iv = iv
        self.bkey = None
        self.preserve = preserve

    def getKey(self):
        log.info('Requesting secret key '
                 '- verify the usage scope and zero + free ')
        return decrypt(self.val, self.iv)

    def getPin(self):
        return decrypt(self.val, self.iv)

    def compare(self, key):
        bhOtpKey = binascii.unhexlify(key)
        enc_otp_key = to_bytes(encrypt(bhOtpKey, self.iv))
        return safe_compare(enc_otp_key, self.val)

    def hmac_digest(self, data_input, hash_algo):
        self._setupKey_()
        h = hmac.new(self.bkey, data_input, hash_algo).digest()
        self._clearKey_(preserve=self.preserve)
        return h

    def aes_ecb_decrypt(self, enc_data):
        '''
        support inplace aes decryption for the yubikey (mode ECB)

        :param enc_data: data, that should be decrypted
        :return: the decrypted data
        '''
        self._setupKey_()
        backend = default_backend()
        cipher = Cipher(algorithms.AES(self.bkey), modes.ECB(),
                        backend=backend)  # nosec B305 # part of Yubikey specification
        decryptor = cipher.decryptor()
        msg_bin = decryptor.update(enc_data) + decryptor.finalize()
        self._clearKey_(preserve=self.preserve)
        return msg_bin

    def _setupKey_(self):
        if self.bkey is None:
            akey = decrypt(self.val, self.iv)
            self.bkey = binascii.unhexlify(akey)
            zerome(akey)
            del akey

    def _clearKey_(self, preserve=False):
        if preserve is False and self.bkey is not None:
            zerome(self.bkey)
            del self.bkey

    # This is used to remove the encryption key from the memory, but
    # this could also disturb the garbage collector and lead to memory eat ups.
    def __del__(self):
        self._clearKey_()


@log_with(log, log_entry=False, log_exit=False)
def hash(val, seed, algo=None):
    """

    :param val: value to hash
    :type val: bytes or str or unicode
    :param seed: seed for the hash
    :type seed: bytes, str, unicode
    :param algo:
    :return: the hexlified hash value calculated from hash and seed
    :rtype: str
    """
    log.debug('hash()')
    m = sha256()
    m.update(to_bytes(val))
    m.update(to_bytes(seed))
    return hexlify_and_unicode(m.digest())


@log_with(log, log_entry=False, log_exit=False)
def pass_hash(password):
    """
    Hash password with crypt context
    :param password: The password to hash
    :type password: str
    :return: The hash string of the password
    """
    DEFAULT_HASH_ALGO_PARAMS.update(get_app_config_value("PI_HASH_ALGO_PARAMS",
                                                         default={}))
    pass_ctx = CryptContext(get_app_config_value("PI_HASH_ALGO_LIST",
                                                 default=DEFAULT_HASH_ALGO_LIST),
                            **DEFAULT_HASH_ALGO_PARAMS)
    pw_dig = pass_ctx.hash(password)
    return pw_dig


@log_with(log, log_entry=False, log_exit=False)
def verify_pass_hash(password, hvalue):
    """
    Verify the hashed password value
    :param password: The plaintext password to verify
    :type password: str
    :param hvalue: The hashed password
    :type hvalue: str
    :return: True if the password matches
    :rtype: bool
    """
    pass_ctx = CryptContext(get_app_config_value("PI_HASH_ALGO_LIST",
                                                 default=DEFAULT_HASH_ALGO_LIST))
    return pass_ctx.verify(password, hvalue)


def hash_with_pepper(password):
    """
    Hash function to hash with salt and pepper. The pepper is read from
    "PI_PEPPER" from pi.cfg.

    Is used with admins and passwordReset

    :param password: the password to hash
    :type password: str
    :return: hashed password string
    :rtype: str
    """
    key = get_app_config_value("PI_PEPPER", "missing")
    return pass_hash(key + password)


def verify_with_pepper(passwordhash, password):
    """
    verify the password hash with the given password and pepper

    :param passwordhash: the passwordhash
    :type passwordhash: str
    :param password: the password to verify
    :type password: str
    :return: whether the password matches the hash
    :rtype: bool
    """
    # get the password pepper
    password = password or ""
    key = get_app_config_value("PI_PEPPER", "missing")

    return verify_pass_hash(key + password, passwordhash)


def init_hsm():
    """
    Initialize the HSM in the app-local store

    The config file pi.cfg may contain PI_HSM_MODULE and parameters like:
    PI_HSM_MODULE_MODULE
    PI_HSM_MODULE_SLOT_ID...

    :return: hsm object
    """
    app_store = get_app_local_store()
    if "pi_hsm" not in app_store or not isinstance(app_store["pi_hsm"], dict):
        config = get_app_config()
        HSM_config = {"obj": create_hsm_object(config)}
        app_store["pi_hsm"] = HSM_config
        log.info("Initialized HSM object {0}".format(HSM_config))
    return app_store["pi_hsm"]["obj"]


def get_hsm(require_ready=True):
    """
    Check that the HSM has been set up properly and return it.
    If it is None, raise a HSMException.
    If it is not ready, raise a HSMException. Optionally, the ready check can be disabled.
    :param require_ready: Check whether the HSM is ready
    :return: a HSM module object
    """
    hsm = init_hsm()
    if hsm is None:
        raise HSMException('hsm is None!')
    if require_ready and not hsm.is_ready:
        raise HSMException('hsm not ready!')
    return hsm


def set_hsm_password(password):
    """
    Set the password for the HSM. Raises an exception if the HSM is already set up.
    :param password: password string
    :return: boolean flag indicating whether the HSM is ready now
    """
    hsm = init_hsm()
    if hsm.is_ready:
        raise HSMException("HSM already set up.")
    return hsm.setup_module({"password": password})


@log_with(log, log_entry=False)
def encryptPassword(password):
    """
    Encrypt given password with hsm

    This function returns a unicode string with a
    hexlified contents of the IV and the encrypted data separated by a
    colon like "4956:44415441"

    :param password: the password
    :type password: bytes or str
    :return: the encrypted password, hexlified
    :rtype: str
    """
    hsm = get_hsm()
    try:
        password = password or ""
        ret = hsm.encrypt_password(to_bytes(password))
    except Exception as exx:  # pragma: no cover
        log.warning(exx)
        log.info(traceback.format_exc())
        raise HSMException("Failed to encrypt password!")
    return ret


@log_with(log, log_entry=False)
def encryptPin(cryptPin):
    """
    :param cryptPin: the pin to encrypt
    :type cryptPin: bytes or str
    :return: the encrypted pin
    :rtype: str
    """
    hsm = get_hsm()
    return to_unicode(hsm.encrypt_pin(to_bytes(cryptPin)))


@log_with(log, log_exit=False)
def decryptPassword(cryptPass):
    """
    Decrypt the encrypted password ``cryptPass`` and return it.
    If an error occurs during decryption, return FAILED_TO_DECRYPT_PASSWORD.

    :param cryptPass: str
    :return: the decrypted password
    :rtype: str
    """
    hsm = get_hsm()
    try:
        ret = hsm.decrypt_password(cryptPass)
    except Exception as exx:
        log.warning(exx)
        ret = FAILED_TO_DECRYPT_PASSWORD
    return ret


@log_with(log, log_exit=False)
def decryptPin(cryptPin):
    """

    :param cryptPin: the encrypted pin
    :type cryptPin: str, bytes, unicode
    :return: the decrypted pin
    :rtype: str
    """
    hsm = get_hsm()
    return hsm.decrypt_pin(cryptPin)


@log_with(log, log_entry=False)
def encrypt(data, iv, key_id=0):
    """
    encrypt a variable from the given input with an initialisation vector

    :param data:   buffer, which contains the value
    :type  data:   bytes or str
    :param iv:     initialisation vector
    :type  iv:     bytes or str
    :param key_id: contains the key id of the keyset which should be used
    :type  key_id: int
    :return: encrypted and hexlified data
    :rtype: str
    """
    hsm = get_hsm()
    ret = hsm.encrypt(to_bytes(data), to_bytes(iv), key_id=key_id)
    return hexlify_and_unicode(ret)


@log_with(log, log_exit=False)
def decrypt(enc_data, iv, key_id=0):
    """
    decrypt a variable from the given input with an initialisation vector

    :param enc_data: buffer, which contains the crypted value
    :type  enc_data: bytes or str
    :param iv:       initialisation vector
    :type  iv:       bytes or str
    :param key_id:   contains the key id of the keyset which should be used
    :type  key_id:   int
    :return: decrypted buffer
    :rtype: bytes
    """
    hsm = get_hsm()
    res = hsm.decrypt(to_bytes(enc_data), to_bytes(iv), key_id=key_id)
    return res


@log_with(log, log_exit=False)
def aes_cbc_decrypt(key, iv, enc_data):
    """
    Decrypts the given cipherdata with AES (CBC Mode) using the key/iv.

    Attention: This function returns the decrypted data as is, without removing
    any padding. The calling function must take care of this!

    :param key: The encryption key
    :type key: bytes
    :param iv: The initialization vector
    :type iv: bytes
    :param enc_data: The cipher text
    :type enc_data: bytes
    :return: plain text in binary data
    :rtype: bytes
    """
    backend = default_backend()
    mode = modes.CBC(iv)
    cipher = Cipher(algorithms.AES(key), mode=mode, backend=backend)
    decryptor = cipher.decryptor()
    output = decryptor.update(enc_data) + decryptor.finalize()
    return output


def aes_cbc_encrypt(key, iv, data):
    """
    encrypts the given data with AES (CBC Mode) using key/iv.

    Attention: This function expects correctly padded input data (multiple of
    AES block size). The calling function must take care of this!

    :param key: The encryption key
    :type key: bytes
    :param iv: The initialization vector
    :type iv: bytes
    :param data: The cipher text
    :type data: bytes
    :return: plain text in binary data
    :rtype: bytes
    """
    if len(data) % (algorithms.AES.block_size // 8) != 0:  # pragma: no cover
        raise ParameterError("Invalid length of input data")
    # do the encryption
    backend = default_backend()
    mode = modes.CBC(iv)
    cipher = Cipher(algorithms.AES(key), mode=mode, backend=backend)
    encryptor = cipher.encryptor()
    output = encryptor.update(data) + encryptor.finalize()
    return output


def aes_encrypt_b64(key, data):
    """
    This function encrypts the data using AES-128-CBC. It generates
    and adds an IV.
    This is used for PSKC.

    :param key: Encryption key (binary format)
    :type key: bytes
    :param data: Data to encrypt
    :type data: bytes
    :return: base64 encrypted output, containing IV and encrypted data
    :rtype: str
    """
    # pad data
    padder = padding.PKCS7(algorithms.AES.block_size).padder()
    padded_data = padder.update(data) + padder.finalize()
    iv = geturandom(16)
    encdata = aes_cbc_encrypt(key, iv, padded_data)
    return b64encode_and_unicode(iv + encdata)


def aes_decrypt_b64(key, enc_data_b64):
    """
    This function decrypts base64 encoded data (containing the IV)
    using AES-128-CBC. Used for PSKC

    :param key: binary key
    :param enc_data_b64: base64 encoded data (IV + encdata)
    :type enc_data_b64: str
    :return: encrypted data
    """
    data_bin = base64.b64decode(enc_data_b64)
    iv = data_bin[:16]
    encdata = data_bin[16:]
    padded_data = aes_cbc_decrypt(key, iv, encdata)

    # remove padding
    unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder()
    output = unpadder.update(padded_data) + unpadder.finalize()

    return output


# @log_with(log)
def geturandom(length=20, hex=False):
    '''
    get random - from the security module

    :param length: length of the returned bytes - default is 20 bytes
    :type length: int
    :param hex: convert result to hexstring
    :type hex: bool

    :return:
    :rtype: bytes, unicode

    '''
    hsm = get_hsm()
    ret = hsm.random(length)

    if hex:
        ret = to_unicode(binascii.hexlify(ret))
    return ret


# some random functions based on geturandom #################################


class urandom(object):
    precision = 12

    @staticmethod
    def random():
        """
        get random float value between 0.0 and 1.0

        :return: float value
        """
        # get a binary random string
        randhex = geturandom(urandom.precision, hex=True)

        # convert this to an integer
        randi = int(randhex, 16) * 1.0

        # get the max integer
        intmax = 2 ** (8 * urandom.precision) * 1.0

        # scale the integer to an float between 0.0 and 1.0
        randf = randi / intmax

        return randf

    @staticmethod
    def uniform(start, end=None):
        """
        get a floating value between start and end

        :param start: start floating value
        :param end: end floating value
        :return: floating value between start and end
        """
        if end is None:
            end = start
            start = 0.0

        # make sure we have a float
        startf = start * 1.0

        dist = (end - start)
        # if end lower than start invert the distance and start at the end
        if dist < 0:
            dist = dist * -1.0
            startf = end * 1.0

        ret = urandom.random()

        # result is start value + stretched distance
        res = startf + ret * dist

        return res

    @staticmethod
    def randint(start, end=None):
        """
        get random integer in between of start and end

        :return: random int
        """
        if end is None:
            end = start
            start = 0

        dist = end - start
        # if end lower than start invert the distance and start at the end
        if dist < 0:
            dist = dist * -1
            start = end

        randf = urandom.random()

        # result is start value + stretched distance
        ret = int(start + randf * dist)

        return ret

    @staticmethod
    def choice(array):
        '''
        get one out of an array

        :param array: sequence - string or list
        :return: array element
        '''
        size = len(array)
        idx = urandom.randint(0, size)
        return array[idx]

    @staticmethod
    def randrange(start, stop=None, step=1):
        """
        get one out of a range of values

        :param start: start of range
        :param stop: end value
        :param step: the step distance between two values

        :return: int value
        """
        if stop is None:
            stop = start
            start = 0
        # see python definition of randrange
        res = urandom.choice(range(start, stop, step))
        return res


def get_rand_digit_str(length=16):
    """
    return a string of digits with a defined length
    using the urandom

    This is used for creating transaction ids of challenges.
    It does not work for length==1!

    :return: random string
    :rtype: basestring
    """
    if length == 1:
        raise ValueError("get_rand_digit_str only works for values > 1")
    clen = int(length / 2.4 + 0.5)
    randd = geturandom(clen, hex=True)
    s = "{0:d}".format((int(randd, 16)))
    if len(s) < length:
        s = "0" * (length - len(s)) + s
    elif len(s) > length:
        s = s[:length]
    return s


def get_alphanum_str(length=16):
    """
    return a string of alphanumeric characters

    :return: random string
    :rtype: basestring
    """
    ret = ""
    for i in range(length):
        ret += secrets.choice(string.ascii_letters + string.digits)
    return ret


def zerome(bufferObject):
    '''
    clear a string value from memory

    :param bufferObject: the string variable, which should be cleared
    :type  bufferObject: string or key buffer

    :return:    - nothing -
    '''
    data = ctypes.POINTER(ctypes.c_char)()
    size = ctypes.c_int()  # Note, int only valid for python 2.5
    ctypes.pythonapi.PyObject_AsCharBuffer(ctypes.py_object(bufferObject),
                                           ctypes.pointer(data),
                                           ctypes.pointer(size))
    ctypes.memset(data, 0, size.value)

    return


def _slow_rsa_verify_raw(key, sig, msg):
    if not (isinstance(sig, int) and isinstance(msg, int)):  # pragma: no cover
        raise ParameterError("Message and signature need to be integer")
    if hasattr(key, 'public_numbers'):
        pn = key.public_numbers()
    elif hasattr(key, 'private_numbers'):  # pragma: no cover
        pn = key.private_numbers().public_numbers
    else:  # pragma: no cover
        raise TypeError('No public key')

    # compute m**d (mod n) and compare the two integers
    return msg == pow(sig, pn.e, pn.n)


class Sign(object):
    """
    Signing class that is used to sign Audit Entries and to sign API responses.
    """
    sig_ver = 'rsa_sha256_pss'

    def __init__(self, private_key=None, public_key=None, check_private_key=True):
        """
        Initialize the Sign object with the given Keys.

        :param private_key: The private Key data in PEM format
        :type private_key: bytes or None
        :param public_key:  The public key data in PEM format
        :type public_key: bytes or None
        :param check_private_key: Check the private key when loading (default: True)
        :type check_private_key: bool
        :return: The Sign Object
        :rtype: Sign
        """
        self.private = None
        self.public = None
        backend = default_backend()
        if private_key:
            try:
                self.private = serialization.load_pem_private_key(private_key,
                                                                  password=None,
                                                                  backend=backend,
                                                                  unsafe_skip_rsa_key_validation=not check_private_key)
            except Exception as e:
                log.error("Error loading private key: ({0!r})".format(e))
                log.debug(traceback.format_exc())
                raise e

        if public_key:
            try:
                self.public = serialization.load_pem_public_key(public_key,
                                                                backend=backend)
            except Exception as e:
                log.error("Error loading public key: ({0!r})".format(e))
                log.debug(traceback.format_exc())
                raise e

    def sign(self, s):
        """
        Create a signature of the string s

        :param s: String to sign
        :type s: str
        :return: The hexlified and versioned signature of the string
        :rtype: str
        """
        if not self.private:
            log.info('Could not sign message {0!s}, no private key!'.format(s))
            # TODO: should we throw an exception in this case?
            return ''

        signature = self.private.sign(
            to_bytes(s),
            asym_padding.PSS(
                mgf=asym_padding.MGF1(hashes.SHA256()),
                salt_length=asym_padding.PSS.MAX_LENGTH),
            hashes.SHA256())
        res = ':'.join([self.sig_ver, hexlify_and_unicode(signature)])
        return res

    def verify(self, s, signature, verify_old_sigs=False):
        """
        Check the signature of the string s

        :param s: String to check
        :type s: str or bytes
        :param signature: the signature to compare
        :type signature: str or int
        :param verify_old_sigs: whether to check for old style signatures as well
        :type verify_old_sigs: bool
        :return: True if the signature is valid, false otherwise.
        :rtype: bool
        """
        r = False
        if not self.public:
            log.info('Could not verify signature for message {0!s}, '
                     'no public key!'.format(s))
            return r

        sver = ''
        try:
            sver, signature = str(signature).split(':')
        except ValueError:
            # if the signature does not contain a colon we assume an old style signature.
            pass

        try:
            if sver == self.sig_ver:
                self.public.verify(
                    binascii.unhexlify(signature),
                    to_bytes(s),
                    asym_padding.PSS(
                        mgf=asym_padding.MGF1(hashes.SHA256()),
                        salt_length=asym_padding.PSS.MAX_LENGTH),
                    hashes.SHA256())
                r = True
            else:
                if verify_old_sigs:
                    int_s = int(binascii.hexlify(sha256(to_bytes(s)).digest()), 16)
                    r = _slow_rsa_verify_raw(self.public, int(signature), int_s)
                else:
                    log.debug('Could not verify old style signature {0!s} '
                              'for data {1:s}'.format(signature, s))
        except Exception:
            log.error("Failed to verify signature: {0!r}".format(s))
            log.debug("{0!s}".format(traceback.format_exc()))

        return r


def create_hsm_object(config):
    """
    This creates an HSM object from the given config dictionary.
    The config dictionary are the values that appear in pi.cfg.

    It is needed PI_HSM_MODULE and all other values
    PI_HSM_MODULE_* depending on the module implementation.

    :param config: A configuration dictionary
    :return: A HSM object
    """
    # We need this to resolve the circular dependency between utils and crypto.
    from privacyidea.lib.utils import get_module_class
    hsm_module_name = config.get("PI_HSM_MODULE",
                                 "privacyidea.lib.security.default.DefaultSecurityModule")
    package_name, class_name = hsm_module_name.rsplit(".", 1)
    hsm_class = get_module_class(package_name, class_name, "setup_module")
    log.info("initializing HSM class: {0!s}".format(hsm_class))
    if class_name == "DefaultSecurityModule":
        hsm_parameters = {"file": config.get("PI_ENCFILE")}
    else:
        # get all parameters by splitting every config entry starting with PI_HSM_MODULE_
        # and pass this as a config object to hsm_class.
        hsm_parameters = {}
        for key in config.keys():
            if key.startswith("PI_HSM_MODULE_"):
                param = key[len("PI_HSM_MODULE_"):].lower()
                hsm_parameters[param] = config.get(key)
        logging_params = dict(hsm_parameters)
        if "password" in logging_params:
            logging_params["password"] = "XXXX"  # nosec B105 # Hide password
        log.info("calling HSM module with parameters {0}".format(logging_params))

    return hsm_class(hsm_parameters)


def generate_otpkey(key_size=20):
    """
    generates the HMAC key of keysize. Should be 20 or 32
    The key is returned as a hexlified string
    :param key_size: The size of the key to generate
    :type key_size: int
    :return: hexlified key
    :rtype: str
    """
    log.debug("generating key of size {0!s}".format(key_size))
    return hexlify_and_unicode(geturandom(key_size))


def generate_password(size=6, characters=string.ascii_lowercase + string.ascii_uppercase + string.digits,
                      requirements=[]):
    """
    Generate a random password of the specified length of the given characters
    with optional requirements

    :param size: The length of the password. If smaller than the number of requirements,
    the requirements define the password length.
    :param characters: The characters the password may consist of
    :param requirements: A list of strings from which the password must contain at least one character.
    Note that the requirements are not automatically added to the base character set.
    :return: password
    :rtype: basestring
    """
    if len(requirements) > size:
        log.info('The number of requirements is larger then the password length.')
    # add one random character from each string in the requirements list
    passwd = [secrets.choice(str) for str in requirements]
    # fill the password until size with allowed characters
    passwd.extend(secrets.choice(characters) for _x in range(size - len(requirements)))
    # Shuffle, so that we mix the requirements and the generic characters
    random.shuffle(passwd)
    return "".join(passwd)


def generate_keypair(rsa_keysize=2048):
    """
    This creates an RSA key pair

    # TODO: The HSM should be used.

    The public key and private keys are returned in PKCS#1 Format.

    :return: tuple of (pubkey, privkey)
    """
    private_key = rsa.generate_private_key(
        public_exponent=65537,
        key_size=rsa_keysize,
        backend=default_backend()
    )
    public_key = private_key.public_key()
    pem_priv = to_unicode(private_key.private_bytes(
        encoding=serialization.Encoding.PEM,
        format=serialization.PrivateFormat.TraditionalOpenSSL,
        encryption_algorithm=serialization.NoEncryption()))
    pem_pub = to_unicode(public_key.public_bytes(
        encoding=serialization.Encoding.PEM,
        format=serialization.PublicFormat.PKCS1))
    return pem_pub, pem_priv


def get_elliptic_curve_instance(curve_name: str):
    """
    Returns an instance of the elliptic curve class for the given curve name.
    It does not support x25519, x448, ed25519, ed448.

    :param curve_name: The name of the elliptic curve to use, e.g. 'secp384r1'
    :return: An EllipticCurve instance or None if the curve is not supported
    """
    class_instance = None
    mod = "cryptography.hazmat.primitives.asymmetric.ec"
    try:
        m = importlib.import_module(mod)
        cls = curve_name.upper()
        class_obj = getattr(m, cls)
        class_instance = class_obj()
    except Exception as ex:  # pragma: no cover
        log.debug(f"Could not import module {cls}: {ex}")

    return class_instance


def get_private_key_class(curve_name):
    """
    Returns the private key class of the given curve.
    Only supports x25519, x448, ed25519, ed448

    :param curve_name: The name of the elliptic curve to use, e.g. 'x25519'
    :return: The private key class object (e.g. X25519PrivateKey) or None if the curve is not supported
    """
    class_obj = None
    mod = "cryptography.hazmat.primitives.asymmetric"
    try:
        cls = f"{curve_name.capitalize()}PrivateKey"
        m = importlib.import_module(f"{mod}.{curve_name.lower()}")
        class_obj = getattr(m, cls)
    except Exception as ex:  # pragma: no cover
        log.debug(f"Could not import module {cls}: {ex}")

    return class_obj


def generate_keypair_ecc(curve_name: str) -> KeyPair:
    """
    Generates a key pair using the given elliptic curve.

    :param curve_name: The name of the elliptic curve to use, e.g. 'secp384r1'
    :return: A KeyPair dataclass containing the public_key and private_key which are either instances of the generic
             classes EllipticCurvePrivateKey and EllipticCurvePublicKey or of a specific class for the curve such as
             X25519PrivateKey and X25519PublicKey.
    """
    ecc_keys = KeyPair()
    # private key
    # first try to use the generic class passing the curve instance
    curve = get_elliptic_curve_instance(curve_name)
    if curve:
        ecc_keys.private_key = ec.generate_private_key(curve)
    else:
        # try to use the specific class
        key_class = get_private_key_class(curve_name)
        if key_class:
            ecc_keys.private_key = key_class.generate()
        else:
            raise ParameterError(f"Unsupported elliptic curve {curve_name}!")

    # Generate public key based on private key
    ecc_keys.public_key = ecc_keys.private_key.public_key()

    return ecc_keys


def ecc_key_pair_to_b64url_str(public_key: EllipticCurvePublicKey = None,
                               private_key: EllipticCurvePrivateKey = None) -> KeyPair:
    """
    Encodes the given ECC key (pair) in urlsafe base64 to a string. If one key is None, an empty string is returned for
    this key.

    :param private_key: An EllipticCurvePrivateKey object or None if only a public key shall be encoded
    :param public_key: An EllipticCurvePublicKey object or None if only a private key shall be encoded
    :return: A KeyPair dataclass containing the private_key and public_key as strings in base64 encoding
    """
    str_keys = KeyPair("", "")
    # Note: PEM only uses b64 not urlsafe
    # TODO: Make this applicable for different encoding formats?
    if private_key:
        private_key_b64 = private_key.private_bytes(encoding=serialization.Encoding.PEM,
                                                    format=serialization.PrivateFormat.PKCS8,
                                                    encryption_algorithm=NoEncryption())
        str_keys.private_key = private_key_b64.decode("utf-8")

    if public_key:
        public_key_b64 = public_key.public_bytes(encoding=serialization.Encoding.PEM,
                                                 format=serialization.PublicFormat.SubjectPublicKeyInfo)
        str_keys.public_key = public_key_b64.decode("utf-8")
    return str_keys


def b64url_str_key_pair_to_ecc_obj(public_key_str: str = None, private_key_str: str = None) -> KeyPair:
    """
    Converts public and private keys as base64url encoded strings to ECC key objects.
    If only one key is given, None is returned for the other one.

    :param public_key_str: The public key in base64url encoding
    :param private_key_str: The private key in base64url encoding
    :return: A tuple of (public_key, private_key) ecc public/private key objects or None if no string is provided
    """
    ecc_keys = KeyPair()
    if private_key_str:
        ecc_keys.private_key = serialization.load_pem_private_key(private_key_str.encode("utf-8"), password=None)

    if public_key_str:
        ecc_keys.public_key = serialization.load_pem_public_key(public_key_str.encode("utf-8"))

    return ecc_keys


def get_hash_algorithm_object(algorithm_name: str, default: HashAlgorithm = None) -> HashAlgorithm:
    """
    Returns the HashAlgorithm object for the given algorithm name.

    :param algorithm_name: The name of the hash algorithm
    :param default: The default hash algorithm object to use if the given one is not found
    :return: The hash algorithm object
    """
    hash_algorithm = default
    classes = hashes.__all__

    # Find correct spelling
    if algorithm_name not in classes:
        # Try with capitalize and uppercase
        if algorithm_name.capitalize() in classes:
            algorithm_name = algorithm_name.capitalize()
        elif algorithm_name.upper() in classes:
            algorithm_name = algorithm_name.upper()

    if algorithm_name in classes:
        hash_class = getattr(hashes, algorithm_name)
        hash_algorithm = hash_class()
    else:
        log.info(f"Unknown hash algorithm {algorithm_name}! Uses {default} instead.")
    return hash_algorithm


def sign_ecc(message: bytes, private_key: EllipticCurvePrivateKey, algorithm_name: str) -> dict:
    """
    Signs a message with the given ecc private key.

    :param message: The message to sign
    :param private_key: The private key ecc object to sign the message
    :param algorithm_name: The name of the hash algorithm to use
    :return: The signature and the name of the hash algorithm that was used as dictionary such as:

        ::
            {
                "signature": <signature, bytes>,
                "hash_algorithm": <algorithm_name, str>
            }
    """
    if isinstance(message, str):
        message = message.encode("utf-8")
    hash_algorithm = get_hash_algorithm_object(algorithm_name, default=hashes.SHA256())
    ecdsa_algorithm = ec.ECDSA(hash_algorithm)
    signature = private_key.sign(message, ecdsa_algorithm)
    return {"signature": signature, "hash_algorithm": hash_algorithm.name}


def verify_ecc(message: bytes, signature: bytes, public_key: EllipticCurvePublicKey,
               algorithm_name: str) -> dict:
    """
    Verifies a signature with the given public key.
    Raises InvalidSignature if the signature is invalid.

    :param message: The message that was signed
    :param signature: The signature to verify
    :param public_key: The public key ecc object to verify the signature
    :param algorithm_name: The name of the hash algorithm to use
    :return: Dictionary containing the key "valid" which is True if the signature is valid
        and the key "hash_algorithm" containing the name of the hash algorithm that was used.
    """
    if isinstance(message, str):
        message = message.encode("utf-8")
    hash_algorithm = get_hash_algorithm_object(algorithm_name, default=hashes.SHA256())
    ecdsa_algorithm = ec.ECDSA(hash_algorithm)
    if public_key:
        public_key.verify(signature, message, ecdsa_algorithm)
        valid = True
    else:
        valid = False
        log.debug("No public key provided for signature verification!")
    return {"valid": valid, "hash_algorithm": hash_algorithm.name}


def ecdh_key_exchange(private_key: X25519PrivateKey, public_key: X25519PublicKey) -> bytes:
    """
    Performs an ECDH key exchange.
    The shared key is calculated from the private and public key. Afterward, a key derivation is performed.

    :param private_key: The private key ecc object
    :param public_key: The public key ecc object
    :return: The shared key as bytes
    """
    shared_key = private_key.exchange(public_key)

    # Perform key derivation.
    derived_key = HKDF(algorithm=hashes.SHA256(),
                       length=32,
                       salt=None,
                       info=None,
                       ).derive(shared_key)
    return derived_key


def encrypt_aes(message: bytes, shared_key: bytes) -> dict:
    """
    Encrypts a message with the given shared key using AES (Advanced Encryption Standard) and GCM (Galois Counter Mode).

    :param message: The message to encrypt
    :param shared_key: The shared key to use for encryption
    :return: Dictionary containing the encrypted message and further parameters required for the decryption such as:

        ::
            {
                "cipher": <encrypted message base64 encoded, str>,
                "algorithm": <algorithm_name, str>,
                "mode": <mode_name, str>,
                "init_vector": <init_vector base64 encoded, str>,
                "tag": <tag base64 encoded, str>
            }
    """
    init_vector = geturandom(16)
    cipher = Cipher(algorithms.AES(shared_key), modes.GCM(init_vector))
    encryptor = cipher.encryptor()
    secret = encryptor.update(message) + encryptor.finalize()

    # convert to strings
    secret_str = base64.urlsafe_b64encode(secret).decode("utf-8")
    init_vector_str = base64.urlsafe_b64encode(init_vector).decode("utf-8")
    tag_str = base64.urlsafe_b64encode(encryptor.tag).decode("utf-8")

    res = {"cipher": secret_str, "algorithm": "AES", "mode": "GCM", "init_vector": init_vector_str,
           "tag": tag_str}
    return res


def decrypt_aes(secret: bytes, shared_key: bytes, params: dict) -> bytes:
    """
    Decrypts a message with the given key.

    :param secret: The encrypted message
    :param shared_key: The shared key to use for decryption
    :param params: The Further parameters for the decryption depending on the algorithm like

        ::
            {
                "mode": "GCM",
                "init_vector": "VPJwIAUl8IYVdbKAb6vu1w==",
                "tag": "Wzuu8O5WYI_xf4Hb14rEBA=="
            }

    :return: The decrypted message
    """
    if isinstance(secret, str):
        secret = base64.urlsafe_b64decode(secret)

    init_vector = base64.urlsafe_b64decode(params['init_vector'])
    tag = base64.urlsafe_b64decode(params['tag'])
    decryptor = Cipher(algorithms.AES(shared_key), modes.GCM(init_vector, tag), ).decryptor()

    # Decryption gets us the authenticated plaintext.
    # If the tag does not match an InvalidTag exception will be raised.
    message = decryptor.update(secret) + decryptor.finalize()
    return message
