#
#  Util/PEM.py : Privacy Enhanced Mail utilities
#
# SPDX-FileCopyrightText: 2014 Legrandin <helderijs@gmail.com>
# SPDX-License-Identifier: BSD-2-Clause

from __future__ import annotations

from typing import Any, Callable, Optional

__all__ = ["encode", "decode"]

import re
from binascii import a2b_base64, b2a_base64, hexlify, unhexlify

from Crypto import (
    _INCORRECT_PASSPHRASE,
    _PASSPHRASE_REQUIRED,
    IncorrectPassphraseError,
    PassphraseRequiredError,
)
from Crypto.Cipher import AES, DES, DES3
from Crypto.Hash import MD5
from Crypto.Protocol.KDF import PBKDF1
from Crypto.Random import get_random_bytes
from Crypto.Util._bytes import tobytes
from Crypto.Util.asn1 import DerSequence
from Crypto.Util.Padding import pad, unpad


def encode(
    data: bytes,
    marker: str,
    passphrase: Optional[bytes] = None,
    randfunc: Optional[Callable[[int], bytes]] = None,
) -> str:
    """Encode a piece of binary data into PEM format.

    Args:
      data (byte string):
        The piece of binary data to encode.
      marker (string):
        The marker for the PEM block (e.g. "PUBLIC KEY").
        Note that there is no official master list for all allowed markers.
        Still, you can refer to the OpenSSL_ source code.
      passphrase (byte string):
        If given, the PEM block will be encrypted. The key is derived from
        the passphrase.
      randfunc (callable):
        Random number generation function; it accepts an integer N and returns
        a byte string of random data, N bytes long. If not given, a new one is
        instantiated.

    Returns:
      The PEM block, as a string.

    .. _OpenSSL: https://github.com/openssl/openssl/blob/master/include/openssl/pem.h
    """

    if randfunc is None:
        randfunc = get_random_bytes

    out = "-----BEGIN %s-----\n" % marker
    if passphrase:
        # We only support 3DES for encryption
        salt = randfunc(8)
        key = PBKDF1(passphrase, salt, 16, 1, MD5)
        key += PBKDF1(key + passphrase, salt, 8, 1, MD5)
        objenc = DES3.new(key, DES3.MODE_CBC, salt)
        out += "Proc-Type: 4,ENCRYPTED\nDEK-Info: DES-EDE3-CBC,%s\n\n" % hexlify(salt).upper().decode(
            "latin-1"
        )
        # Encrypt with PKCS#7 padding
        data = objenc.encrypt(pad(data, objenc.block_size))
    elif passphrase is not None:
        raise ValueError("Empty password")

    # Each BASE64 line can take up to 64 characters (=48 bytes of data)
    # b2a_base64 adds a new line character!
    chunks = [b2a_base64(data[i : i + 48]).decode("latin-1") for i in range(0, len(data), 48)]
    out += "".join(chunks)
    out += "-----END %s-----" % marker
    return out


def _EVP_BytesToKey(data, salt, key_len):
    d = [b""]
    m = (key_len + 15) // 16
    for _ in range(m):
        nd = MD5.new(d[-1] + data + salt).digest()
        d.append(nd)
    return b"".join(d)[:key_len]


def decode(pem_data: str, passphrase: Optional[bytes] = None) -> tuple[bytes, str, bool]:
    """Decode a PEM block into binary.

    Args:
      pem_data (string):
        The PEM block.
      passphrase (byte string):
        If given and the PEM block is encrypted,
        the key will be derived from the passphrase.

    Returns:
      A tuple with the binary data, the marker string, and a boolean to
      indicate if decryption was performed.

    Raises:
      PassphraseRequiredError: (a ``ValueError``) if the PEM block is encrypted
        and no passphrase has been provided.
      IncorrectPassphraseError: (a ``ValueError``) if the PEM block cannot
        be decrypted with the passphrase (most likely, the passphrase is incorrect).
        The content of an encrypted PEM block must be DER (as for a private key).
      ValueError: if decoding fails for any other reason.
    """

    # Verify Pre-Encapsulation Boundary
    r = re.compile(r"\s*-----BEGIN (.*)-----\s+")
    m = r.match(pem_data)
    if not m:
        raise ValueError("Not a valid PEM pre boundary")
    marker = m.group(1)

    # Verify Post-Encapsulation Boundary.
    if not pem_data.rstrip().endswith("-----END %s-----" % marker):
        raise ValueError("Not a valid PEM post boundary")

    # Removes spaces and slit on lines
    lines = pem_data.replace(" ", "").split()
    if len(lines) < 3:
        raise ValueError("A PEM file must have at least 3 lines")

    # Decrypts, if necessary
    if lines[1].startswith("Proc-Type:4,ENCRYPTED"):
        if not passphrase:
            raise PassphraseRequiredError(_PASSPHRASE_REQUIRED)
        DEK = lines[2].split(":")
        if len(DEK) != 2 or DEK[0] != "DEK-Info":
            raise ValueError("PEM encryption format not supported.")
        algo, salt_hex = DEK[1].split(",")
        salt = unhexlify(tobytes(salt_hex))

        padding = True

        objdec: Any
        if algo == "DES-CBC":
            key = _EVP_BytesToKey(passphrase, salt, 8)
            objdec = DES.new(key, DES.MODE_CBC, salt)
        elif algo == "DES-EDE3-CBC":
            key = _EVP_BytesToKey(passphrase, salt, 24)
            objdec = DES3.new(key, DES3.MODE_CBC, salt)
        elif algo == "AES-128-CBC":
            key = _EVP_BytesToKey(passphrase, salt[:8], 16)
            objdec = AES.new(key, AES.MODE_CBC, salt)
        elif algo == "AES-192-CBC":
            key = _EVP_BytesToKey(passphrase, salt[:8], 24)
            objdec = AES.new(key, AES.MODE_CBC, salt)
        elif algo == "AES-256-CBC":
            key = _EVP_BytesToKey(passphrase, salt[:8], 32)
            objdec = AES.new(key, AES.MODE_CBC, salt)
        elif algo.lower() == "id-aes256-gcm":
            key = _EVP_BytesToKey(passphrase, salt[:8], 32)
            objdec = AES.new(key, AES.MODE_GCM, nonce=salt)
            padding = False
        else:
            raise ValueError("Unsupported PEM encryption algorithm (%s)." % algo)
        lines = lines[2:]
    else:
        objdec = None

    # Decode body
    data = a2b_base64("".join(lines[1:-1]))
    enc_flag = False
    if objdec:
        if padding:
            try:
                data = unpad(objdec.decrypt(data), objdec.block_size)
            except ValueError:
                raise IncorrectPassphraseError(_INCORRECT_PASSPHRASE) from None
        else:
            # There is no tag, so we don't use decrypt_and_verify
            data = objdec.decrypt(data)
        enc_flag = True

        # An encrypted PEM block holds a private key, encoded in DER.
        # With an incorrect passphrase, the padding may still be correct
        # by chance (and GCM is used without its tag): the result is garbage.
        try:
            DerSequence().decode(data)
        except ValueError:
            raise IncorrectPassphraseError(_INCORRECT_PASSPHRASE) from None

    return (data, marker, enc_flag)
