<?php

namespace iDServer\Internal;

use Exception;
use FG\ASN1\Universal\Integer;
use FG\ASN1\Universal\Sequence;
use Mdanter\Ecc\Crypto\Signature\Signer;
use Mdanter\Ecc\Crypto\Signature\SignHasher;
use Mdanter\Ecc\EccFactory;
use Mdanter\Ecc\Primitives\GeneratorPoint;
use Mdanter\Ecc\Random\RandomGeneratorFactory;
use Mdanter\Ecc\Serializer\PrivateKey\DerPrivateKeySerializer;
use Mdanter\Ecc\Serializer\PrivateKey\PemPrivateKeySerializer;
use Mdanter\Ecc\Serializer\PublicKey\DerPublicKeySerializer;
use Mdanter\Ecc\Serializer\PublicKey\PemPublicKeySerializer;
use Mdanter\Ecc\Serializer\Signature\DerSignatureSerializer;
use RobRichards\XMLSecLibs\XMLSecurityKey as XMLSecurityKeyBase;

/**
 * This class is an extended version of the original {@see XMLSecurityKeyBase}.
 * It adds ECC support.
 */
class XMLSecurityKey extends XMLSecurityKeyBase
{
    /**
     * http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha1
     * http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha224
     * http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256
     * http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384
     * http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512
     */
    const ECC_SECP256R1 = 'http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256';
    const ECC_SECP384R1 = 'http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384';
    // note: the name SECP521 while using sha512 in the URL is not a typo
    const ECC_SECP521R1 = 'http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512';

    const EG_ALGORITHMS = [
        self::ECC_SECP256R1,
        self::ECC_SECP384R1,
        self::ECC_SECP521R1,
    ];

    /**
     * To store the parameters that belong to EGAlgorithms
     * The original array is private in the parent class.
     * @var array
     */
    private $cryptParams = [];

    /**
     * @param string $type
     * @param null|array $params
     * @throws Exception When $params is an array but 'type' is not in the array set as "public" or "private".
     */
    function __construct($type, $params = null)
    {
        if ($this->isEGAlgorithm($type)) {
            // TODO: handle if different parameters have to be added
            $this->type = $type;
            $this->cryptParams['method'] = $type;
            $this->cryptParams['library'] = 'openssl';
            if (is_array($params) && !empty($params['type'])) {
                if ($params['type'] == 'public' || $params['type'] == 'private') {
                    $this->cryptParams['type'] = $params['type'];
                    return;
                }
            }
            throw new Exception('Certificate "type" (private/public) must be passed via parameters');
        } else {
            parent::__construct($type, $params);
        }
    }

    /**
     * Returns the URL of the algorithm like http://www.w3.org/2001/04/xmldsig-more#rsa-sha256
     *
     * @return string
     */
    public function getAlgorithm(): string
    {
        if (empty($this->cryptParams['method'])) {
            return parent::getAlgorithm();
        }
        return $this->cryptParams['method'];
    }

    /**
     * Returns the SHA algorithm used for the ECC signing.
     *
     * @return string sha256 | sha384 | sha512
     * @throws Exception When the returned value of {@method getAlgorithm()} is not supported by this method.
     *                   It should never occur.
     */
    public function getSHAAlgorithm(): string
    {
        switch ($this->getAlgorithm()) {
            case self::ECC_SECP256R1:
                return 'sha256';
            case self::ECC_SECP384R1:
                return 'sha384';
            case self::ECC_SECP521R1:
                return 'sha512';
        }

        throw new Exception('Unsupported algorithm: ' . $this->getAlgorithm());
    }

    /**
     * Returns a compatible generator object based on the SHA algorithm.
     *
     * Note: sha512 requires generator 521, not generator 512.
     *
     * @param string $shaAlgorithm sha256 | sha384 | sha512
     * @return GeneratorPoint
     * @throws Exception When the passed SHA algorithm is not supported
     */
    public function sha2eccGenerator(string $shaAlgorithm): GeneratorPoint
    {
        switch ($shaAlgorithm) {
            case 'sha256':
                return EccFactory::getNistCurves()->generator256();
            case 'sha384':
                return EccFactory::getNistCurves()->generator384();
            case 'sha512':
                return EccFactory::getNistCurves()->generator521();
        }

        throw new Exception('Unsupported SHA algorithm: ' . $shaAlgorithm);
    }

    /**
     * Returns true if the algorithm should be handled in this class not in the parent.
     *
     * EG means E-Group (implemented by E-Group, not in the original library)
     *
     * @param string $algorithm
     * @return bool
     */
    public function isEGAlgorithm(string $algorithm): bool
    {
        return in_array($algorithm, self::EG_ALGORITHMS);
    }

    /**
     * Loads the given key, or - with isFile set true - the key from the keyfile.
     *
     * @param string $key
     * @param bool $isFile
     * @param bool $isCert
     * @throws Exception
     */
    public function loadKey($key, $isFile = false, $isCert = false)
    {
        if (!$this->isEGAlgorithm($this->getAlgorithm())) {
            parent::loadKey($key, $isFile, $isCert);
            return;
        }

        if ($isFile) {
            $openSSLKeys = file_get_contents($key);
        } else {
            $openSSLKeys = $key;
        }

        if ($isCert) {
            $opensslResource = openssl_x509_read($openSSLKeys);
            $certAsString = "";
            openssl_x509_export($opensslResource, $certAsString);
            $openSSLKeys = $certAsString;
        }

        switch ($this->cryptParams['type']) {
            case 'public':
                $this->key = openssl_get_publickey($openSSLKeys);
                if (!$this->key) {
                    throw new Exception('Unable to extract public key');
                }
                break;

            case 'private':
                if (version_compare(PHP_VERSION, '8.1.0', '<')) {
                    $this->key = openssl_get_privatekey($openSSLKeys, $this->passphrase);
                } else {
                    $this->key = $openSSLKeys;
                }
                break;

            default:
                throw new Exception('Unknown type');
        }
    }

    /**
     * Create and return signature for a string
     *
     * @param string $data
     * @return mixed|string
     * @throws Exception
     */
    public function signData($data)
    {
        if (!$this->isEGAlgorithm($this->getAlgorithm())) {
            return parent::signData($data);
        }
        $adapter = EccFactory::getAdapter();
        $algorithm = $this->getSHAAlgorithm();
        $generator = $this->sha2eccGenerator($algorithm);

        ## You'll be restoring from a key, as opposed to generating one.
        $pemSerializer = new PemPrivateKeySerializer(new DerPrivateKeySerializer($adapter));

        if (version_compare(PHP_VERSION, '8.1.0', '<')) {
            $privateKeyAsString = '';
            openssl_pkey_export($this->key, $privateKeyAsString);
        } else {
            $privateKeyAsString = $this->key;
        }

        $key = $pemSerializer->parse($privateKeyAsString);

        $hasher = new SignHasher($algorithm, $adapter);
        $hash = $hasher->makeHash($data, $generator);

        $random = RandomGeneratorFactory::getRandomGenerator();
        $randomK = $random->generate($generator->getOrder());

        $signer = new Signer($adapter);

        /*
         * We serialize the signature before parsing R and S in the serialized format
         * due to errors that happened when the binary R and S was exported using gmp_export()
         */
        $signature = $signer->sign($key, $hash, $randomK);
        $serializer = new DerSignatureSerializer();
        $serializedSig = $serializer->serialize($signature);

        $iDServerSig = ASN1\Signature::fromBinary($serializedSig);

        return $iDServerSig->toXMLSignatureBinary();
    }

    /**
     * Verify signature
     *
     * @param string $data
     * @param string $signature
     * @return bool|int
     * @throws Exception In case of invalid algorithm
     */
    public function verifySignature($data, $signature)
    {
        if (!$this->isEGAlgorithm($this->getAlgorithm())) {
            return parent::verifySignature($data, $signature);
        }

        $adapter = EccFactory::getAdapter();
        $algorithm = $this->getSHAAlgorithm();
        $generator = $this->sha2eccGenerator($algorithm);

        // Parse signature
        $sigSerializer = new DerSignatureSerializer();

        $length = strlen($signature);
        $r = substr($signature, 0, $length / 2);
        $s = substr($signature, $length / 2);

        $sequence = new Sequence(
            new Integer(gmp_strval(gmp_import($r))),
            new Integer(gmp_strval(gmp_import($s)))
        );

        $sig = $sigSerializer->parse($sequence->getBinary());

        $derSerializer = new DerPublicKeySerializer($adapter);
        $pemSerializer = new PemPublicKeySerializer($derSerializer);

        $keyDetails = openssl_pkey_get_details($this->key);
        $key = $pemSerializer->parse($keyDetails['key']);

        $hasher = new SignHasher($algorithm);
        $hash = $hasher->makeHash($data, $generator);

        $signer = new Signer($adapter);
        return $signer->verify($key, $sig, $hash);
    }
}
