<?php
/**
 * Zend Framework (http://framework.zend.com/)
 *
 * @link      http://github.com/zendframework/zf2 for the canonical source repository
 * @copyright Copyright (c) 2005-2015 Zend Technologies USA Inc. (http://www.zend.com)
 * @license   http://framework.zend.com/license/new-bsd New BSD License
 */

namespace Zend\Crypt\Symmetric;

use Traversable;
use Zend\Stdlib\ArrayUtils;

/**
 * Symmetric encryption using the Mcrypt extension
 *
 * NOTE: DO NOT USE only this class to encrypt data.
 * This class doesn't provide authentication and integrity check over the data.
 * PLEASE USE Zend\Crypt\BlockCipher instead!
 */
class Mcrypt implements SymmetricInterface
{
    const DEFAULT_PADDING = 'pkcs7';

    /**
     * Key
     *
     * @var string
     */
    protected $key;

    /**
     * IV
     *
     * @var string
     */
    protected $iv;

    /**
     * Encryption algorithm
     *
     * @var string
     */
    protected $algo = 'aes';

    /**
     * Encryption mode
     *
     * @var string
     */
    protected $mode = 'cbc';

    /**
     * Padding
     *
     * @var Padding\PaddingInterface
     */
    protected $padding;

    /**
     * Padding plugins
     *
     * @var PaddingPluginManager
     */
    protected static $paddingPlugins = null;

    /**
     * Supported cipher algorithms
     *
     * @var array
     */
    protected $supportedAlgos = array(
        'aes'          => 'rijndael-128',
        'blowfish'     => 'blowfish',
        'des'          => 'des',
        '3des'         => 'tripledes',
        'tripledes'    => 'tripledes',
        'cast-128'     => 'cast-128',
        'cast-256'     => 'cast-256',
        'rijndael-128' => 'rijndael-128',
        'rijndael-192' => 'rijndael-192',
        'rijndael-256' => 'rijndael-256',
        'saferplus'    => 'saferplus',
        'serpent'      => 'serpent',
        'twofish'      => 'twofish'
    );

    /**
     * Supported encryption modes
     *
     * @var array
     */
    protected $supportedModes = array(
        'cbc'  => 'cbc',
        'cfb'  => 'cfb',
        'ctr'  => 'ctr',
        'ofb'  => 'ofb',
        'nofb' => 'nofb',
        'ncfb' => 'ncfb'
    );

    /**
     * Constructor
     *
     * @param  array|Traversable                  $options
     * @throws Exception\RuntimeException
     * @throws Exception\InvalidArgumentException
     */
    public function __construct($options = array())
    {
        if (!extension_loaded('mcrypt')) {
            throw new Exception\RuntimeException(
                'You cannot use ' . __CLASS__ . ' without the Mcrypt extension'
            );
        }
        if (!empty($options)) {
            if ($options instanceof Traversable) {
                $options = ArrayUtils::iteratorToArray($options);
            } elseif (!is_array($options)) {
                throw new Exception\InvalidArgumentException(
                    'The options parameter must be an array, a Zend\Config\Config object or a Traversable'
                );
            }
            foreach ($options as $key => $value) {
                switch (strtolower($key)) {
                    case 'algo':
                    case 'algorithm':
                        $this->setAlgorithm($value);
                        break;
                    case 'mode':
                        $this->setMode($value);
                        break;
                    case 'key':
                        $this->setKey($value);
                        break;
                    case 'iv':
                    case 'salt':
                        $this->setSalt($value);
                        break;
                    case 'padding':
                        $plugins       = static::getPaddingPluginManager();
                        $padding       = $plugins->get($value);
                        $this->padding = $padding;
                        break;
                }
            }
        }
        $this->setDefaultOptions($options);
    }

    /**
     * Set default options
     *
     * @param  array $options
     * @return void
     */
    protected function setDefaultOptions($options = array())
    {
        if (!isset($options['padding'])) {
            $plugins       = static::getPaddingPluginManager();
            $padding       = $plugins->get(self::DEFAULT_PADDING);
            $this->padding = $padding;
        }
    }

    /**
     * Returns the padding plugin manager.  If it doesn't exist it's created.
     *
     * @return PaddingPluginManager
     */
    public static function getPaddingPluginManager()
    {
        if (static::$paddingPlugins === null) {
            self::setPaddingPluginManager(new PaddingPluginManager());
        }

        return static::$paddingPlugins;
    }

    /**
     * Set the padding plugin manager
     *
     * @param  string|PaddingPluginManager        $plugins
     * @throws Exception\InvalidArgumentException
     * @return void
     */
    public static function setPaddingPluginManager($plugins)
    {
        if (is_string($plugins)) {
            if (!class_exists($plugins)) {
                throw new Exception\InvalidArgumentException(sprintf(
                    'Unable to locate padding plugin manager via class "%s"; class does not exist',
                    $plugins
                ));
            }
            $plugins = new $plugins();
        }
        if (!$plugins instanceof PaddingPluginManager) {
            throw new Exception\InvalidArgumentException(sprintf(
                'Padding plugins must extend %s\PaddingPluginManager; received "%s"',
                __NAMESPACE__,
                (is_object($plugins) ? get_class($plugins) : gettype($plugins))
            ));
        }
        static::$paddingPlugins = $plugins;
    }

    /**
     * Get the maximum key size for the selected cipher and mode of operation
     *
     * @return int
     */
    public function getKeySize()
    {
        return mcrypt_get_key_size($this->supportedAlgos[$this->algo], $this->supportedModes[$this->mode]);
    }

    /**
     * Set the encryption key
     * If the key is longer than maximum supported, it will be truncated by getKey().
     *
     * @param  string                             $key
     * @throws Exception\InvalidArgumentException
     * @return Mcrypt
     */
    public function setKey($key)
    {
        $keyLen = strlen($key);

        if (!$keyLen) {
            throw new Exception\InvalidArgumentException('The key cannot be empty');
        }
        $keySizes = mcrypt_module_get_supported_key_sizes($this->supportedAlgos[$this->algo]);
        $maxKey = $this->getKeySize();

        /*
         * blowfish has $keySizes empty, meaning it can have arbitrary key length.
         * the others are more picky.
         */
        if (!empty($keySizes) && $keyLen < $maxKey) {
            if (!in_array($keyLen, $keySizes)) {
                throw new Exception\InvalidArgumentException(
                    "The size of the key must be one of " . implode(", ", $keySizes) . " bytes or longer"
                );
            }
        }
        $this->key = $key;

        return $this;
    }

    /**
     * Get the encryption key
     *
     * @return string
     */
    public function getKey()
    {
        if (empty($this->key)) {
            return null;
        }
        return substr($this->key, 0, $this->getKeySize());
    }

    /**
     * Set the encryption algorithm (cipher)
     *
     * @param  string                             $algo
     * @throws Exception\InvalidArgumentException
     * @return Mcrypt
     */
    public function setAlgorithm($algo)
    {
        if (!array_key_exists($algo, $this->supportedAlgos)) {
            throw new Exception\InvalidArgumentException(
                "The algorithm $algo is not supported by " . __CLASS__
            );
        }
        $this->algo = $algo;

        return $this;
    }

    /**
     * Get the encryption algorithm
     *
     * @return string
     */
    public function getAlgorithm()
    {
        return $this->algo;
    }

    /**
     * Set the padding object
     *
     * @param  Padding\PaddingInterface $padding
     * @return Mcrypt
     */
    public function setPadding(Padding\PaddingInterface $padding)
    {
        $this->padding = $padding;

        return $this;
    }

    /**
     * Get the padding object
     *
     * @return Padding\PaddingInterface
     */
    public function getPadding()
    {
        return $this->padding;
    }

    /**
     * Encrypt
     *
     * @param  string                             $data
     * @throws Exception\InvalidArgumentException
     * @return string
     */
    public function encrypt($data)
    {
        // Cannot encrypt empty string
        if (!is_string($data) || $data === '') {
            throw new Exception\InvalidArgumentException('The data to encrypt cannot be empty');
        }
        if (null === $this->getKey()) {
            throw new Exception\InvalidArgumentException('No key specified for the encryption');
        }
        if (null === $this->getSalt()) {
            throw new Exception\InvalidArgumentException('The salt (IV) cannot be empty');
        }
        if (null === $this->getPadding()) {
            throw new Exception\InvalidArgumentException('You have to specify a padding method');
        }
        // padding
        $data = $this->padding->pad($data, $this->getBlockSize());
        $iv   = $this->getSalt();
        // encryption
        $result = mcrypt_encrypt(
            $this->supportedAlgos[$this->algo],
            $this->getKey(),
            $data,
            $this->supportedModes[$this->mode],
            $iv
        );

        return $iv . $result;
    }

    /**
     * Decrypt
     *
     * @param  string                             $data
     * @throws Exception\InvalidArgumentException
     * @return string
     */
    public function decrypt($data)
    {
        if (empty($data)) {
            throw new Exception\InvalidArgumentException('The data to decrypt cannot be empty');
        }
        if (null === $this->getKey()) {
            throw new Exception\InvalidArgumentException('No key specified for the decryption');
        }
        if (null === $this->getPadding()) {
            throw new Exception\InvalidArgumentException('You have to specify a padding method');
        }
        $iv         = substr($data, 0, $this->getSaltSize());
        $ciphertext = substr($data, $this->getSaltSize());
        $result     = mcrypt_decrypt(
            $this->supportedAlgos[$this->algo],
            $this->getKey(),
            $ciphertext,
            $this->supportedModes[$this->mode],
            $iv
        );
        // unpadding
        return $this->padding->strip($result);
    }

    /**
     * Get the salt (IV) size
     *
     * @return int
     */
    public function getSaltSize()
    {
        return mcrypt_get_iv_size($this->supportedAlgos[$this->algo], $this->supportedModes[$this->mode]);
    }

    /**
     * Get the supported algorithms
     *
     * @return array
     */
    public function getSupportedAlgorithms()
    {
        return array_keys($this->supportedAlgos);
    }

    /**
     * Set the salt (IV)
     *
     * @param  string                             $salt
     * @throws Exception\InvalidArgumentException
     * @return Mcrypt
     */
    public function setSalt($salt)
    {
        if (empty($salt)) {
            throw new Exception\InvalidArgumentException('The salt (IV) cannot be empty');
        }
        if (strlen($salt) < $this->getSaltSize()) {
            throw new Exception\InvalidArgumentException(
                'The size of the salt (IV) must be at least ' . $this->getSaltSize() . ' bytes'
            );
        }
        $this->iv = $salt;

        return $this;
    }

    /**
     * Get the salt (IV) according to the size requested by the algorithm
     *
     * @return string
     */
    public function getSalt()
    {
        if (empty($this->iv)) {
            return null;
        }
        if (strlen($this->iv) < $this->getSaltSize()) {
            throw new Exception\RuntimeException(
                'The size of the salt (IV) must be at least ' . $this->getSaltSize() . ' bytes'
            );
        }

        return substr($this->iv, 0, $this->getSaltSize());
    }

    /**
     * Get the original salt value
     *
     * @return string
     */
    public function getOriginalSalt()
    {
        return $this->iv;
    }

    /**
     * Set the cipher mode
     *
     * @param  string                             $mode
     * @throws Exception\InvalidArgumentException
     * @return Mcrypt
     */
    public function setMode($mode)
    {
        if (!empty($mode)) {
            $mode = strtolower($mode);
            if (!array_key_exists($mode, $this->supportedModes)) {
                throw new Exception\InvalidArgumentException(
                    "The mode $mode is not supported by " . __CLASS__
                );
            }
            $this->mode = $mode;
        }

        return $this;
    }

    /**
     * Get the cipher mode
     *
     * @return string
     */
    public function getMode()
    {
        return $this->mode;
    }

    /**
     * Get all supported encryption modes
     *
     * @return array
     */
    public function getSupportedModes()
    {
        return array_keys($this->supportedModes);
    }

    /**
     * Get the block size
     *
     * @return int
     */
    public function getBlockSize()
    {
        return mcrypt_get_block_size($this->supportedAlgos[$this->algo], $this->supportedModes[$this->mode]);
    }
}
