<?php
/**
 * @package   FOF
 * @copyright Copyright (c)2010-2022 Nicholas K. Dionysopoulos / Akeeba Ltd
 * @license   GNU General Public License version 3, or later
 */

namespace FOF40\Encrypt;

defined('_JEXEC') || die;

use FOF40\Encrypt\AesAdapter\AdapterInterface;
use FOF40\Encrypt\AesAdapter\OpenSSL;

/**
 * A simple abstraction to AES encryption
 *
 * Usage:
 *
 * // Create a new instance.
 * $aes = new Aes();
 * // Set the encryption password. It's expanded to a key automatically.
 * $aes->setPassword('yourPassword');
 * // Encrypt something.
 * $cipherText = $aes->encryptString($sourcePlainText);
 * // Decrypt something
 * $plainText = $aes->decryptString($sourceCipherText);
 */
class Aes
{
	/**
	 * The cipher key.
	 *
	 * @var   string
	 */
	private $key = '';

	/**
	 * The AES encryption adapter in use.
	 *
	 * @var  AdapterInterface
	 */
	private $adapter;

	/**
	 * Initialise the AES encryption object.
	 *
	 * @param   string   $mode     Encryption mode. Can be ebc or cbc. We recommend using cbc.
	 */
	public function __construct(string $mode = 'cbc')
	{
		$this->adapter = new OpenSSL();

		$this->adapter->setEncryptionMode($mode);
	}

	/**
	 * Is AES encryption supported by this PHP installation?
	 *
	 * @return boolean
	 */
	public static function isSupported(): bool
	{
		$adapter = new OpenSSL();

		if (!$adapter->isSupported())
		{
			return false;
		}

		if (!\function_exists('base64_encode'))
		{
			return false;
		}

		if (!\function_exists('base64_decode'))
		{
			return false;
		}

		if (!\function_exists('hash_algos'))
		{
			return false;
		}

		$algorithms = \hash_algos();

		return in_array('sha256', $algorithms);
	}

	/**
	 * Sets the password for this instance.
	 *
	 * @param   string  $password  The password (either user-provided password or binary encryption key) to use
	 */
	public function setPassword(string $password)
	{
		$this->key = $password;
	}

	/**
	 * Encrypts a string using AES
	 *
	 * @param   string  $stringToEncrypt  The plaintext to encrypt
	 * @param   bool    $base64encoded    Should I Base64-encode the result?
	 *
	 * @return   string  The cryptotext. Please note that the first 16 bytes of
	 *                   the raw string is the IV (initialisation vector) which
	 *                   is necessary for decoding the string.
	 */
	public function encryptString(string $stringToEncrypt, bool $base64encoded = true): string
	{
		$blockSize = $this->adapter->getBlockSize();
		$randVal   = new Randval();
		$iv        = $randVal->generate($blockSize);

		$key        = $this->getExpandedKey($blockSize, $iv);
		$cipherText = $this->adapter->encrypt($stringToEncrypt, $key, $iv);

		// Optionally pass the result through Base64 encoding
		if ($base64encoded)
		{
			$cipherText = base64_encode($cipherText);
		}

		// Return the result
		return $cipherText;
	}

	/**
	 * Decrypts a ciphertext into a plaintext string using AES
	 *
	 * @param   string  $stringToDecrypt  The ciphertext to decrypt. The first 16 bytes of the raw string must contain
	 *                                    the IV (initialisation vector).
	 * @param   bool    $base64encoded    Should I Base64-decode the data before decryption?
	 * @param   bool    $legacy           Use legacy key expansion? Use it to decrypt date encrypted with FOF 3.
	 *
	 * @return   string  The plain text string
	 */
	public function decryptString(string $stringToDecrypt, bool $base64encoded = true, bool $legacy = false): string
	{
		if ($base64encoded)
		{
			$stringToDecrypt = base64_decode($stringToDecrypt);
		}

		// Extract IV
		$iv_size = $this->adapter->getBlockSize();
		$strLen  = function_exists('mb_strlen') ? mb_strlen($stringToDecrypt, 'ASCII') : strlen($stringToDecrypt);

		// If the string is not big enough to have an Initialization Vector in front then, clearly, it is not encrypted.
		if ($strLen < $iv_size)
		{
			return '';
		}

		// Get the IV, the key and decrypt the string
		$iv  = substr($stringToDecrypt, 0, $iv_size);
		$key = $this->getExpandedKey($iv_size, $iv, $legacy);

		return $this->adapter->decrypt($stringToDecrypt, $key);
	}

	/**
	 * Performs key expansion using PBKDF2
	 *
	 * CAVEAT: If your password ($this->key) is the same size as $blockSize you don't get key expansion. Practically,
	 * it means that you should avoid using 16 byte passwords.
	 *
	 * @param   int     $blockSize  Block size in bytes. This should always be 16 since we only deal with 128-bit AES
	 *                              here.
	 * @param   string  $iv         The initial vector. Use Randval::generate($blockSize)
	 * @param   bool    $legacy     Use legacy key expansion? Only ever use to decrypt data encrypted with FOF 3.
	 *
	 * @return string
	 */
	public function getExpandedKey(int $blockSize, string $iv, bool $legacy = false): string
	{
		$key        = $legacy ? $this->legacyKey($this->key) : $this->key;
		$passLength = strlen($key);

		if (function_exists('mb_strlen'))
		{
			$passLength = mb_strlen($key, 'ASCII');
		}

		if ($passLength !== $blockSize)
		{
			$iterations = 1000;
			$salt       = $this->adapter->resizeKey($iv, 16);
			$key        = hash_pbkdf2('sha256', $this->key, $salt, $iterations, $blockSize, true);
		}

		return $key;
	}

	/**
	 * Process the password the same way FOF 3 did.
	 *
	 * This is a very bad idea. It would get a password, calculate its SHA-256 and throw half of it away. The rest was
	 * used as the encryption key. In FOF 4 we use a far more sane key expansion using PKKDF2 with SHA-256 and 1000
	 * rounds.
	 *
	 * @param   $password
	 *
	 * @return  string
	 * @since   4.0.0
	 */
	private function legacyKey($password): string
	{
		$passLength = strlen($password);

		if (function_exists('mb_strlen'))
		{
			$passLength = mb_strlen($password, 'ASCII');
		}

		if ($passLength === 32)
		{
			return $password;
		}

		// Legacy mode was doing something stupid, requiring a key of 32 bytes. DO NOT USE LEGACY MODE!
		// Legacy mode: use the sha256 of the password
		$key = hash('sha256', $password, true);
		// We have to trim or zero pad the password (we end up throwing half of it away in Rijndael-128 / AES...)
		$key = $this->adapter->resizeKey($key, $this->adapter->getBlockSize());

		return $key;
	}
}

/**
 * Compatibility mode for servers lacking the hash_pbkdf2 PHP function (typically, the hash extension is installed but
 * PBKDF2 was not compiled into it). This is really slow but since it's used sparingly you shouldn't notice a
 * substantial performance degradation under most circumstances.
 */
if (!function_exists('hash_pbkdf2'))
{
	function hash_pbkdf2($algo, $password, $salt, $count, $length = 0, $raw_output = false)
	{
		if (!in_array(strtolower($algo), hash_algos()))
		{
			trigger_error(__FUNCTION__ . '(): Unknown hashing algorithm: ' . $algo, E_USER_WARNING);
		}

		if (!is_numeric($count))
		{
			trigger_error(__FUNCTION__ . '(): expects parameter 4 to be long, ' . gettype($count) . ' given', E_USER_WARNING);
		}

		if (!is_numeric($length))
		{
			trigger_error(__FUNCTION__ . '(): expects parameter 5 to be long, ' . gettype($length) . ' given', E_USER_WARNING);
		}

		if ($count <= 0)
		{
			trigger_error(__FUNCTION__ . '(): Iterations must be a positive integer: ' . $count, E_USER_WARNING);
		}

		if ($length < 0)
		{
			trigger_error(__FUNCTION__ . '(): Length must be greater than or equal to 0: ' . $length, E_USER_WARNING);
		}

		$output      = '';
		$block_count = $length ? ceil($length / strlen(hash($algo, '', $raw_output))) : 1;

		for ($i = 1; $i <= $block_count; $i++)
		{
			$last = $xorsum = hash_hmac($algo, $salt . pack('N', $i), $password, true);

			for ($j = 1; $j < $count; $j++)
			{
				$xorsum ^= ($last = hash_hmac($algo, $last, $password, true));
			}

			$output .= $xorsum;
		}

		if (!$raw_output)
		{
			$output = bin2hex($output);
		}

		return $length ? substr($output, 0, $length) : $output;
	}
}
