<?php
/**
 * PHP Password Library
 *
 * @package PHPass\Hashes
 * @category Cryptography
 * @author Ryan Chouinard <rchouinard at gmail.com>
 * @license http://www.opensource.org/licenses/mit-license.html MIT License
 * @link https://github.com/rchouinard/phpass Project at GitHub
 */

namespace Phpass\Hash\Adapter;

use Phpass\Exception\InvalidArgumentException;
use Phpass\Exception\RuntimeException;

/**
 * PBKDF2 hash adapter
 *
 * @package PHPass\Hashes
 * @category Cryptography
 * @author Ryan Chouinard <rchouinard at gmail.com>
 * @license http://www.opensource.org/licenses/mit-license.html MIT License
 * @link https://github.com/rchouinard/phpass Project at GitHub
 */
class Pbkdf2 extends Base
{

    const DIGEST_SHA1 = 'sha1';
    const DIGEST_SHA256 = 'sha256';
    const DIGEST_SHA512 = 'sha512';

    /**
     * Hashing algorithm used by the PBKDF2 implementation.
     *
     * @var string
     */
    protected $_algo = self::DIGEST_SHA512;

    /**
     * Cost value used to generate new hash values.
     *
     * @var integer
     */
    protected $_iterationCount = 12000;

    /**
     * Return a hashed string.
     *
     * @param string $password
     *   The string to be hashed.
     * @param string $salt
     *   An optional salt string to base the hashing on. If not provided, a
     *   suitable string is generated by the adapter.
     * @return string
     *   Returns the hashed string. On failure, a standard crypt error string
     *   is returned which is guaranteed to differ from the salt.
     * @throws RuntimeException
     *   A RuntimeException is thrown on failure if
     *   self::$_throwExceptionOnFailure is true.
     */
    public function crypt($password, $salt = null)
    {
        if (!$salt) {
            $salt = $this->genSalt();
        }

        $hash = '*0';
        if ($this->verify($salt)) {
            $matches = array ();
            preg_match('/^\$pbkdf2(?:-(?P<digest>sha256|sha512))?\$(?P<rounds>\d+)\$(?P<salt>[\.\/0-9A-Za-z]{0,1366})\$?/', $salt, $matches);
            if ($matches['digest'] == '') {
                $matches['digest'] = $matches[1] = self::DIGEST_SHA1;
            }

            $keySize = 64;
            if ($matches['digest'] == self::DIGEST_SHA256) {
                $keySize = 32;
            } elseif ($matches['digest'] == self::DIGEST_SHA1) {
                $keySize = 20;
            }

            $salt = '';
            if ($matches['salt'] != '') {
                $salt = str_replace('.', '+', $matches['salt']);
                switch (strlen($salt) & 0x03) {
                    case 0:
                        $salt = base64_decode($salt);
                        break;
                    case 2:
                        $salt = base64_decode($salt . '==');
                        break;
                    case 3:
                        $salt = base64_decode($salt . '=');
                        break;
                    default:
                        return $hash;
                }
            }

            $checksum = $this->_pbkdf2($password, $salt, $matches['rounds'], $keySize, $matches['digest']);
            $hash = '$pbkdf2';
            if ($matches['digest'] != self::DIGEST_SHA1) {
                $hash .= '-' . $matches['digest'];
            }
            $hash .= '$' . $matches['rounds'] . '$' .
                str_replace(array ('+', '=', "\n"), array ('.', '', ''), base64_encode($salt)) . '$' .
                str_replace(array ('+', '=', "\n"), array ('.', '', ''), base64_encode($checksum));
        }

        if (!$this->verifyHash($hash)) {
            $hash = ($salt != '*0') ? '*0' : '*1';
            if ($this->_throwExceptionOnFailure) {
                throw new RuntimeException('Failed generating a valid hash', $hash);
            }
        }

        return $hash;
    }

    /**
     * Generate a salt string compatible with this adapter.
     *
     * @param string $input
     *   Optional random 48-bit string to use when generating the salt.
     * @return string
     *   Returns the generated salt string.
     */
    public function genSalt($input = null)
    {
        if (!$input) {
            $input = $this->_getRandomBytes(16);
        }

        $identifier = 'pbkdf2';
        if ($this->_algo === self::DIGEST_SHA256 || $this->_algo === self::DIGEST_SHA512) {
            $identifier .= '-' . $this->_algo;
        }

        $count = min(max($this->_iterationCount, 1), 4294967296);

        $salt = str_replace(array ('+', '=', "\n"), array ('.', '', ''), base64_encode($input));

        // $pbkdf2-<digest>$<rounds>$<salt>$
        return '$' . $identifier . '$' . $count . '$' . $salt . '$';
    }

    /**
     * Set adapter options.
     *
     * Expects an associative array of option keys and values used to configure
     * this adapter.
     *
     * <dl>
     *   <dt>digest</dt>
     *     <dd>Hash digest to use when calculating the checksum. Must be one
     *     of sha1, sha256, or sha512. Defaults to sha512.</dd>
     *   <dt>iterationCount</dt>
     *     <dd>Iteration count for the underlying PBKDF2 hashing algorithm.
     *     Must be in range 1 - 4294967296. Defaults to 12000.</dd>
     * </dl>
     *
     * @param Array $options
     *   Associative array of adapter options.
     * @return self
     *   Returns an instance of self to support method chaining.
     * @throws InvalidArgumentException
     *   Throws an InvalidArgumentException if a provided option key contains
     *   an invalid value.
     * @see Base::setOptions()
     */
    public function setOptions(Array $options)
    {
        parent::setOptions($options);
        $options = array_change_key_case($options, CASE_LOWER);

        foreach ($options as $key => $value) {
            switch ($key) {
                case 'digest':
                    $value = strtolower($value);
                    if (!in_array($value, array (self::DIGEST_SHA1, self::DIGEST_SHA256, self::DIGEST_SHA512))) {
                        throw new InvalidArgumentException('Digest must be one of sha1, sha256, or sha512');
                    }
                    $this->_algo = $value;
                    break;
                case 'iterationcount':
                    if ($value < 1 || $value > 4294967296) {
                        throw new InvalidArgumentException('Iteration count must be between 1 and 4294967296');
                    }
                    $this->_iterationCount = $value;
                    break;
                default:
                    break;
            }
        }

        return $this;
    }

    /**
     * Check if a hash string is valid for the current adapter.
     *
     * @since 2.1.0
     * @param string $input
     *   Hash string to verify.
     * @return boolean
     *   Returns true if the input string is a valid hash value, false
     *   otherwise.
     */
    public function verifyHash($input)
    {
        return ($this->verifySalt($input) && 1 === preg_match('/^\$pbkdf2(?:-(?P<digest>sha256|sha512))?\$(?P<rounds>\d+)\$(?P<salt>[\.\/0-9A-Za-z]{0,1366})\$(?P<checksum>[\.\/0-9A-Za-z]{27,86})$/', $input));
    }

    /**
     * Check if a salt string is valid for the current adapter.
     *
     * @since 2.1.0
     * @param string $input
     *   Salt string to verify.
     * @return boolean
     *   Returns true if the input string is a valid salt value, false
     *   otherwise.
     */
    public function verifySalt($input)
    {
        $valid = false;
        $matches = array ();
        if (1 === preg_match('/^\$pbkdf2(?:-(?P<digest>sha256|sha512))?\$(?P<rounds>\d+)\$(?P<salt>[\.\/0-9A-Za-z]{0,1366})\$?/', $input, $matches)) {
            $digest = $matches['digest'] ?: self::DIGEST_SHA1;
            $rounds = $matches['rounds'];
            $salt = $matches['salt'];

            $digestValid = false;
            if (in_array($digest, array (self::DIGEST_SHA1, self::DIGEST_SHA256, self::DIGEST_SHA512))) {
                $digestValid = true;
            }

            $roundsValid = false;
            if ($rounds[0] != '0' && $rounds >= 1 && $rounds <= 4294967296) {
                $roundsValid = true;
            }

            if ($digestValid && $roundsValid) {
                $valid = true;
            }
        }

        return $valid;
    }

    /**
     * Internal implementation of PKCS #5 v2.0.
     *
     * This implementation passes tests using vectors given in RFC 6070 s.2,
     * PBKDF2 HMAC-SHA1 Test Vectors. Vectors given for PBKDF2 HMAC-SHA2 at
     * http://stackoverflow.com/questions/5130513 also pass.
     *
     * @param string $password
     *   The string to be hashed.
     * @param string $salt
     *   Salt value used by the HMAC function.
     * @param integer $iterationCount
     *   Number of iterations for key stretching.
     * @param integer $keyLength
     *   Length of derived key.
     * @param string $algo
     *   Algorithm to use when generating HMAC digest.
     * @return string
     *   Returns the raw hash string.
     */
    protected function _pbkdf2($password, $salt, $iterationCount = 1000, $keyLength = 20, $algo = 'sha1')
    {
        $hashLength = strlen(hash($algo, null, true));
        $keyBlocks = ceil($keyLength / $hashLength);
        $derivedKey = '';

        for ($block = 1; $block <= $keyBlocks; ++$block) {
            $iteratedBlock = $currentBlock = hash_hmac($algo, $salt . pack('N', $block), $password, true);
            for ($iteration = 1; $iteration < $iterationCount; ++$iteration) {
                $iteratedBlock ^= $currentBlock = hash_hmac($algo, $currentBlock, $password, true);
            }

            $derivedKey .= $iteratedBlock;
        }

        return substr($derivedKey, 0, $keyLength);
    }

}
