<?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;

/**
 * PHPass portable hash adapter
 *
 * Implements a hashing algorithm compatible with the original Openwall phpass
 * portable hash.
 *
 * @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 Portable extends Base
{

    /**
     * Logarithmic cost value used when generating new hash values.
     *
     * @var integer
     */
    protected $_iterationCountLog2 = 12;

    /**
     * Flag indicating if new hashes should use phpBB hash identifiers.
     *
     * By default, new hashes will use the $P$ identifier. If this flag is set
     * to true, new hashes will use the $H$ identifier.
     *
     * @var boolean
     */
    protected $_phpBBCompat = false;

    /**
     * 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)) {
            $count = 1 << strpos($this->_itoa64, $salt[3]);
            $checksum = md5(substr($salt, 4, 8) . $password, true);
            do {
                $checksum = md5($checksum . $password, true);
            } while (--$count);
            $hash = substr($salt, 0, 12) . $this->_encode64($checksum, 16);
        }

        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(6);
        }

        // Hash identifier
        $identifier = $this->_phpBBCompat ? 'H' : 'P';

        // Cost factor
        $costFactor = $this->_itoa64[min($this->_iterationCountLog2 + 5, 30)];

        // Salt string
        $salt = $this->_encode64($input, 6);

        return '$' . $identifier . '$' . $costFactor . $salt;
    }

    /**
     * Set adapter options.
     *
     * Expects an associative array of option keys and values used to configure
     * this adapter.
     *
     * <dl>
     *   <dt>iterationCountLog2</dt>
     *     <dd>Base-2 logarithm of the iteration count for the underlying
     *     Blowfish-based hashing algorithm. Must be in range 7 - 30.
     *     Defaults to 12.</dd>
     *   <dt>phpBBCompat</dt>
     *     <dd>If true, new hashes will use the phpBB identifier $H$ instead of
     *     the standard $P$. Defaults to false.</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 'iterationcountlog2':
                    $value = (int) $value;
                    if ($value < 7 || $value > 30) {
                        throw new InvalidArgumentException('Iteration count must be between 7 and 30');
                    }
                    $this->_iterationCountLog2 = $value;
                    break;
                case 'phpbbcompat':
                    $this->_phpBBCompat = (bool) $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(substr($input, 0, -22)) && 1 === preg_match('/^[\.\/0-9A-Za-z]{22}$/', substr($input, -22)));
    }

    /**
     * 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)
    {
        $appearsValid =  (1 === preg_match('/^\$[PH]{1}\$[\.\/0-9A-Za-z]{1}[\.\/0-9A-Za-z]{8}$/', $input));
        if ($appearsValid) {
            $costFactor = strpos($this->_itoa64, $input[3]);
            if ($costFactor < 7 || $costFactor > 30) {
                $appearsValid = false;
            }
        }

        return $appearsValid;
    }

}
