<?php
/**
 * Copyright © Magento, Inc. All rights reserved.
 * See COPYING.txt for license details.
 */

declare(strict_types=1);

namespace Magento\Framework\Encryption;

use Magento\Framework\App\DeploymentConfig;
use Magento\Framework\App\ObjectManager;
use Magento\Framework\Encryption\Adapter\EncryptionAdapterInterface;
use Magento\Framework\Encryption\Helper\Security;
use Magento\Framework\Math\Random;
use Magento\Framework\Encryption\Adapter\SodiumChachaIetf;
use Magento\Framework\Encryption\Adapter\Mcrypt;

/**
 * Class Encryptor provides basic logic for hashing strings and encrypting/decrypting misc data.
 *
 * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
 */
class Encryptor implements EncryptorInterface
{
    /**
     * Key of md5 algorithm
     */
    const HASH_VERSION_MD5 = 0;

    /**
     * Key of sha256 algorithm
     */
    const HASH_VERSION_SHA256 = 1;

    /**
     * Key of Argon2ID13 algorithm
     */
    public const HASH_VERSION_ARGON2ID13 = 2;

    /**
     * Key of Argon2ID13 algorithm that works on any PHP and libsodium version.
     */
    public const HASH_VERSION_ARGON2ID13_AGNOSTIC = 3;

    /**
     * Key of latest used algorithm
     *
     * @deprecated Latest version is dynamic based on current setup.
     * @see \Magento\Framework\Encryption\Encryptor::getLatestHashVersion
     */
    const HASH_VERSION_LATEST = 3;

    /**
     * Default length of salt in bytes
     */
    const DEFAULT_SALT_LENGTH = 32;

    /**#@+
     * Exploded password hash keys
     */
    const PASSWORD_HASH = 0;
    const PASSWORD_SALT = 1;
    const PASSWORD_VERSION = 2;
    /**#@-*/

    /**
     * Array key of encryption key in deployment config
     */
    const PARAM_CRYPT_KEY = 'crypt/key';

    /**#@+
     * Cipher versions
     */
    const CIPHER_BLOWFISH = 0;

    const CIPHER_RIJNDAEL_128 = 1;

    const CIPHER_RIJNDAEL_256 = 2;

    const CIPHER_AEAD_CHACHA20POLY1305 = 3;

    const CIPHER_LATEST = 3;
    /**#@-*/

    /**
     * Default hash string delimiter
     */
    const DELIMITER = ':';

    /**
     * Map of simple hash versions
     *
     * @var array
     */
    private $hashVersionMap = [
        self::HASH_VERSION_MD5 => 'md5',
        self::HASH_VERSION_SHA256 => 'sha256'
    ];

    /**
     * Indicate cipher
     *
     * @var int
     */
    protected $cipher = self::CIPHER_LATEST;

    /**
     * Version of encryption key
     *
     * @var int
     */
    protected $keyVersion;

    /**
     * Array of encryption keys
     *
     * @var string[]
     */
    protected $keys = [];

    /**
     * @var Random
     */
    private $random;

    /**
     * @var KeyValidator
     */
    private $keyValidator;

    /**
     * Encryptor constructor.
     *
     * @param Random $random
     * @param DeploymentConfig $deploymentConfig
     * @param KeyValidator|null $keyValidator
     */
    public function __construct(
        Random $random,
        DeploymentConfig $deploymentConfig,
        KeyValidator $keyValidator = null
    ) {
        $this->random = $random;

        // load all possible keys
        $this->keys = preg_split('/\s+/s', trim((string)$deploymentConfig->get(self::PARAM_CRYPT_KEY)));
        $this->keyVersion = count($this->keys) - 1;
        $this->keyValidator = $keyValidator ?: ObjectManager::getInstance()->get(KeyValidator::class);
    }

    /**
     * Gets latest hash algorithm version.
     *
     * @return int
     */
    public function getLatestHashVersion(): int
    {
        if (extension_loaded('sodium') && defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13')) {
            return self::HASH_VERSION_ARGON2ID13;
        }

        return self::HASH_VERSION_SHA256;
    }

    /**
     * Check whether specified cipher version is supported
     *
     * Returns matched supported version or throws exception
     *
     * @param int $version
     * @return int
     * @throws \Exception
     */
    public function validateCipher($version)
    {
        $types = [
            self::CIPHER_BLOWFISH,
            self::CIPHER_RIJNDAEL_128,
            self::CIPHER_RIJNDAEL_256,
            self::CIPHER_AEAD_CHACHA20POLY1305,
        ];

        $version = (int)$version;
        if (!in_array($version, $types, true)) {
            // phpcs:ignore Magento2.Exceptions.DirectThrow
            throw new \Exception((string)new \Magento\Framework\Phrase('Not supported cipher version'));
        }
        return $version;
    }

    /**
     * @inheritdoc
     *
     * @SuppressWarnings(PHPMD.CyclomaticComplexity)
     */
    public function getHash($password, $salt = false, $version = self::HASH_VERSION_LATEST)
    {
        if ($version < 0 || $version > $this->getLatestHashVersion()) {
            $version = $this->getLatestHashVersion();
        }
        $isArgon = $version === self::HASH_VERSION_ARGON2ID13 || $version === self::HASH_VERSION_ARGON2ID13_AGNOSTIC;

        if ($salt === false) {
            //Generating a simple hash without salt.
            if ($isArgon) {
                $version = self::HASH_VERSION_SHA256;
            }

            return $this->hash($password, $version);
        }
        if ($salt === true) {
            //Generate random default length salt
            $salt = self::DEFAULT_SALT_LENGTH;
        }
        if (is_integer($salt)) {
            //Generate salt of given length.
            $salt = $this->random->getRandomString($salt);
        }

        if ($isArgon) {
            //phpcs:disable PHPCompatibility.Constants.NewConstants
            $seedBytes = SODIUM_CRYPTO_SIGN_SEEDBYTES;
            $opsLimit = SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE;
            $memLimit = SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE;
            if ($version === self::HASH_VERSION_ARGON2ID13_AGNOSTIC) {
                $version = implode('_', [self::HASH_VERSION_ARGON2ID13_AGNOSTIC, $seedBytes, $opsLimit, $memLimit]);
            }

            //phpcs:enable PHPCompatibility.Constants.NewConstants
            $hash = $this->getArgonHash($password, $seedBytes, $opsLimit, $memLimit, $salt);
        } else {
            $hash = $this->generateSimpleHash($salt . $password, (int)$version);
        }

        return implode(
            self::DELIMITER,
            [
                $hash,
                $salt,
                $version
            ]
        );
    }

    /**
     * Generate simple hash for given string.
     *
     * @param string $data
     * @param int $version
     * @return string
     */
    private function generateSimpleHash(string $data, int $version): string
    {
        if (!array_key_exists($version, $this->hashVersionMap)) {
            throw new \InvalidArgumentException('Unknown hashing algorithm');
        }

        return hash($this->hashVersionMap[$version], (string)$data);
    }

    /**
     * @inheritdoc
     */
    public function hash($data, $version = self::HASH_VERSION_SHA256)
    {
        if (empty($this->keys[$this->keyVersion])) {
            throw new \RuntimeException('No key available');
        }
        if (!array_key_exists($version, $this->hashVersionMap)) {
            throw new \InvalidArgumentException('Unknown hashing algorithm');
        }

        return hash_hmac($this->hashVersionMap[$version], (string)$data, $this->keys[$this->keyVersion], false);
    }

    /**
     * @inheritdoc
     */
    public function validateHash($password, $hash)
    {
        return $this->isValidHash($password, $hash);
    }

    /**
     * @inheritdoc
     */
    public function isValidHash($password, $hash)
    {
        $agnosticArgonRegEx = '/^' .self::HASH_VERSION_ARGON2ID13_AGNOSTIC
            .'\_(?<seed>\d+)\_(?<ops>\d+)\_(?<mem>\d+)$/';
        try {
            [$hash, $hashSalt, $hashVersions] = $this->explodePasswordHash($hash);
            $recreated = $password;
            //Upgraded hashes would have been hashed with multiple algorithms.
            //Hashing the test string with every algorithm the original string has been hashed with.
            foreach ($hashVersions as $hashVersion) {
                if (is_string($hashVersion) && preg_match($agnosticArgonRegEx, $hashVersion, $argonParams)) {
                    $recreated = $this->getArgonHash(
                        $recreated,
                        (int)$argonParams['seed'],
                        (int)$argonParams['ops'],
                        (int)$argonParams['mem'],
                        $hashSalt
                    );
                } elseif ((int)$hashVersion === self::HASH_VERSION_ARGON2ID13) {
                    //phpcs:disable PHPCompatibility.Constants.NewConstants
                    $recreated = $this->getArgonHash(
                        $recreated,
                        SODIUM_CRYPTO_SIGN_SEEDBYTES,
                        SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE,
                        SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE,
                        $hashSalt
                    );
                    //phpcs:enable PHPCompatibility.Constants.NewConstants
                } else {
                    $recreated = $this->generateSimpleHash($hashSalt . $recreated, (int)$hashVersion);
                }
            }
        } catch (\Throwable $exception) {
            //Hash is not a password hash.
            $recreated = $this->hash($password);
        }

        return Security::compareStrings(
            $recreated,
            $hash
        );
    }

    /**
     * @inheritdoc
     */
    public function validateHashVersion($hash, $validateCount = false)
    {
        try {
            $hashVersions = $this->explodePasswordHash($hash)[2];
        } catch (\RuntimeException $exception) {
            //Not a password hash.
            return true;
        }
        if ($this->getLatestHashVersion() === self::HASH_VERSION_ARGON2ID13_AGNOSTIC) {
            //Agnostic Argon also stores Argon parameters.
            $validVersion = preg_match(
                '/^' .self::HASH_VERSION_ARGON2ID13_AGNOSTIC .'\_\d+\_\d+\_\d+$/',
                end($hashVersions)
            );
        } else {
            $validVersion = (int)end($hashVersions) === $this->getLatestHashVersion();
        }

        return $validVersion && (!$validateCount || count($hashVersions) === 1);
    }

    /**
     * Explode password hash
     *
     * @param string $hash
     * @return array
     * @throws \RuntimeException When given hash cannot be processed.
     */
    private function explodePasswordHash($hash)
    {
        $explodedPassword = explode(self::DELIMITER, $hash, 3);
        if (count($explodedPassword) !== 3) {
            throw new \RuntimeException('Hash is not a password hash');
        }

        //Hashes that have been upgraded will have algorithm version history starting from the oldest one used.
        $explodedPassword[self::PASSWORD_VERSION] = explode(
            self::DELIMITER,
            $explodedPassword[self::PASSWORD_VERSION]
        );

        return $explodedPassword;
    }

    /**
     * Prepend key and cipher versions to encrypted data after encrypting
     *
     * @param string $data
     * @return string
     */
    public function encrypt($data)
    {
        $crypt = new SodiumChachaIetf($this->keys[$this->keyVersion]);

        return $this->keyVersion .
            ':' . self::CIPHER_AEAD_CHACHA20POLY1305 .
            ':' . base64_encode($crypt->encrypt($data));
    }

    /**
     * Encrypt data using the fastest available algorithm
     *
     * @param string $data
     * @return string
     */
    public function encryptWithFastestAvailableAlgorithm($data)
    {
        $crypt = $this->getCrypt();
        if (null === $crypt) {
            return $data;
        }
        return $this->keyVersion .
            ':' . $this->getCipherVersion() .
            ':' . base64_encode($crypt->encrypt($data));
    }

    /**
     * Look for key and crypt versions in encrypted data before decrypting
     *
     * Unsupported/unspecified key version silently fallback to the oldest we have
     * Unsupported cipher versions eventually throw exception
     * Unspecified cipher version fallback to the oldest we support
     *
     * @param string $data
     * @return string
     * @throws \Exception
     */
    public function decrypt($data)
    {
        if ($data) {
            $parts = explode(':', $data, 4);
            $partsCount = count($parts);

            $initVector = null;
            // specified key, specified crypt, specified iv
            if (4 === $partsCount) {
                list($keyVersion, $cryptVersion, $iv, $data) = $parts;
                $initVector = $iv ? $iv : null;
                $keyVersion = (int)$keyVersion;
                $cryptVersion = self::CIPHER_RIJNDAEL_256;
                // specified key, specified crypt
            } elseif (3 === $partsCount) {
                list($keyVersion, $cryptVersion, $data) = $parts;
                $keyVersion = (int)$keyVersion;
                $cryptVersion = (int)$cryptVersion;
                // no key version = oldest key, specified crypt
            } elseif (2 === $partsCount) {
                list($cryptVersion, $data) = $parts;
                $keyVersion = 0;
                $cryptVersion = (int)$cryptVersion;
                // no key version = oldest key, no crypt version = oldest crypt
            } elseif (1 === $partsCount) {
                $keyVersion = 0;
                $cryptVersion = self::CIPHER_BLOWFISH;
                // not supported format
            } else {
                return '';
            }
            // no key for decryption
            if (!isset($this->keys[$keyVersion])) {
                return '';
            }
            $crypt = $this->getCrypt($this->keys[$keyVersion], $cryptVersion, $initVector);
            if (null === $crypt) {
                return '';
            }
            return trim($crypt->decrypt(base64_decode((string)$data)));
        }
        return '';
    }

    /**
     * Validate key contains only allowed characters
     *
     * @param string|null $key NULL value means usage of the default key specified on constructor
     * @throws \Exception
     */
    public function validateKey($key)
    {
        if (!$this->keyValidator->isValid($key)) {
            // phpcs:ignore Magento2.Exceptions.DirectThrow
            throw new \Exception(
                (string)new \Magento\Framework\Phrase(
                    'Encryption key must be 32 character string without any white space.'
                )
            );
        }
    }

    /**
     * Attempt to append new key & version
     *
     * @param string $key
     * @return $this
     * @throws \Exception
     */
    public function setNewKey($key)
    {
        $this->validateKey($key);
        $this->keys[] = $key;
        $this->keyVersion += 1;
        return $this;
    }

    /**
     * Export current keys as string
     *
     * @return string
     */
    public function exportKeys()
    {
        return implode("\n", $this->keys);
    }

    /**
     * Initialize crypt module if needed
     *
     * By default initializes with latest key and crypt versions
     *
     * @param string $key
     * @param int $cipherVersion
     * @param string $initVector
     * @return EncryptionAdapterInterface|null
     * @throws \Exception
     */
    private function getCrypt(
        string $key = null,
        int $cipherVersion = null,
        string $initVector = null
    ): ?EncryptionAdapterInterface {
        //phpcs:disable PHPCompatibility.Constants.RemovedConstants
        if (null === $key && null === $cipherVersion) {
            $cipherVersion = $this->getCipherVersion();
        }

        if (null === $key) {
            $key = $this->keys[$this->keyVersion];
        }

        if (!$key) {
            return null;
        }

        if (null === $cipherVersion) {
            $cipherVersion = $this->cipher;
        }
        $cipherVersion = $this->validateCipher($cipherVersion);

        if ($cipherVersion >= self::CIPHER_AEAD_CHACHA20POLY1305) {
            return new SodiumChachaIetf($key);
        }

        if ($cipherVersion === self::CIPHER_RIJNDAEL_128) {
            $cipher = MCRYPT_RIJNDAEL_128;
            $mode = MCRYPT_MODE_ECB;
        } elseif ($cipherVersion === self::CIPHER_RIJNDAEL_256) {
            $cipher = MCRYPT_RIJNDAEL_256;
            $mode = MCRYPT_MODE_CBC;
        } else {
            $cipher = MCRYPT_BLOWFISH;
            $mode = MCRYPT_MODE_ECB;
        }
        //phpcs:enable PHPCompatibility.Constants.RemovedConstants

        return new Mcrypt($key, $cipher, $mode, $initVector);
    }

    /**
     * Get cipher version
     *
     * @return int
     */
    private function getCipherVersion()
    {
        if (extension_loaded('sodium')) {
            return $this->cipher;
        } else {
            return self::CIPHER_RIJNDAEL_256;
        }
    }

    /**
     * Generate Argon2ID13 hash.
     *
     * @param string $data
     * @param int $seedBytes
     * @param int $opsLimit
     * @param int $memLimit
     * @param string $salt
     * @return string
     * @throws \SodiumException
     */
    private function getArgonHash(
        string $data,
        int $seedBytes,
        int $opsLimit,
        int $memLimit,
        string $salt
    ): string {
        //phpcs:disable PHPCompatibility.Constants.NewConstants
        if (strlen($salt) < SODIUM_CRYPTO_PWHASH_SALTBYTES) {
            $salt = str_pad($salt, SODIUM_CRYPTO_PWHASH_SALTBYTES, $salt);
        } elseif (strlen($salt) > SODIUM_CRYPTO_PWHASH_SALTBYTES) {
            $salt = substr($salt, 0, SODIUM_CRYPTO_PWHASH_SALTBYTES);
        }

        return bin2hex(
        //phpcs:disable PHPCompatibility.FunctionUse.NewFunctions
            sodium_crypto_pwhash(
                $seedBytes,
                $data,
                $salt,
                $opsLimit,
                $memLimit,
                SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13
            )
        );
        //phpcs:enable PHPCompatibility.Constants.NewConstants
    }
}
