<?php declare(strict_types=1); namespace ArrayIterator\Hashing; use Exception; use function chr; use function crypt; use function max; use function min; use function mt_rand; use function openssl_random_pseudo_bytes; use function password_algos; use function password_hash; use function password_needs_rehash; use function password_verify; use function preg_match; use function random_bytes; use function str_contains; use function str_starts_with; use function strlen; use function strtolower; use const PASSWORD_DEFAULT; /** * Portable Password Hashing Library based on OpenWall PassWord Hash. * Change that follows requirements. * This class based on static method for password_hash & open wall compatibility * Minimum requirements php-7.4 * Algorithm will ignore if not supported or using portable method * * * # * ## CREATE HASHED PASSWORD * * - Portable Password always return 34 characters length, * it will retry 10 times if failed to generate portable password. * If not possible, it will use password_hash() method * * ``` * $password = 'password'; * $cost = 10; * $portable = false; * $algo = PASSWORD_DEFAULT; * $hash = PasswordHash::hash($password, $portable, $cost, $algo); * ``` * * # * ## VERIFY PASSWORD & HASH * * - Method verify will automatically detect password hash type (portable or not) * - If portable, it will use crypt() method * - If not portable, it will use password_verify() * * ``` * $isVerified = PasswordHash::verify($password, $hash); // boolean * ``` * * # * ## CHECK IF PASSWORD NEED REHASH * * - Method will check if it was valid portable password or not, if it was invalid rule * it will use password_needs_rehash() method * - The password need rehash also check the crypt(c) hash (2a, 2b, 2c, 2x) * * ``` * $isNeedRehash = PasswordHash::needRehash($hash); // boolean * ``` * * # * @link https://en.wikipedia.org/wiki/Crypt_(C), * @link http://www.openwall.com/phpass/ */ final class PasswordHash { /** * Default Cost for Hashing * default using 10 that follow password_hash() */ public const DEFAULT_COST = 10; /** * 64 Character for Encoding [0-9A-Za-z./] */ public const ITO_A64 = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; /** * @var ?array available password algorithms */ private static ?array $passwordAlgorithms = null; /** * Create random bytes * * @param int $bytes * @return string */ public static function randomBytes(int $bytes) : string { try { $random = random_bytes($bytes); } catch (Exception $e) { $random = openssl_random_pseudo_bytes($bytes); } if (empty($random)) { $random = ''; while (strlen($random) < $bytes) { $random .= chr(mt_rand(0, 255)); } } return $random; } /** * Encode 64 * * @param string $input * @param int $count * @return string */ protected static function encode64(string $input, int $count): string { $output = ''; $iteration = 0; $maxOffset = strlen(self::ITO_A64) - 1; do { $value = ord($input[$iteration++]); $output .= self::ITO_A64[$value & $maxOffset]; if ($iteration < $count) { $value |= ord($input[$iteration]) << 8; // 8 bit } $output .= self::ITO_A64[($value >> 6) & $maxOffset]; // 6 bit if ($iteration++ >= $count) { break; } if ($iteration < $count) { $value |= ord($input[$iteration]) << 16; // 16 bit } $output .= self::ITO_A64[($value >> 12) & $maxOffset]; // 12 bit if ($iteration++ >= $count) { break; } $output .= self::ITO_A64[($value >> 18) & $maxOffset]; // 18 bit } while ($iteration < $count); return $output; } /** * Crypt password * * @param string $password * @param string $setting * @return string */ private static function crypt(string $password, string $setting): string { $output = '*0'; if (str_starts_with($setting, $output)) { $output = '*1'; } // see generate salt if (strlen($setting) < 12) { return $output; } // find log offset $countLog = strpos(self::ITO_A64, $setting[3]); if ($countLog < 7 || $countLog > 30) { return $output; } $count = 1 << $countLog; $salt = substr($setting, 4, 8); if (strlen($salt) !== 8) { return $output; } // create hash based of salt and password $hash = md5($salt . $password, true); // loop based on 8 bit count do { $hash = md5($hash . $password, true); } while (--$count); // output, 12 char + 22 char $output = substr($setting, 0, 12); $output .= self::encode64($hash, 16); return $output; } /** * Check if hash is portable * * @param string $hash * @return bool */ public static function isPortable(string $hash): bool { return strlen($hash) !== 34 || preg_match('~^\$[PH]\$[0-9a-zA-Z/.]{31}$~', $hash) === 1; } /** * Verify password * * @param string $password * @param string $hash * @return bool */ public static function verify(string $password, string $hash): bool { /* * - Portable Hash: 34 * - Password BCRYPT: 60 * - Argon-2i: 95 / 96 * - password hash should start with $ */ $length = strlen($hash); if ($length < 34 || $hash[0] !== '$') { return false; } // check if hash is not portable if ($length !== 34 || $hash[2] !== '$') { return !str_contains('PH', $hash[1]) && password_verify($password, $hash); } $crypt = self::crypt($password, $hash); $hash = str_starts_with($crypt, '*') ? crypt($password, $hash) : $hash; return $crypt === $hash; } /** * Check if password need rehash * * @param string $hash * @return bool */ public static function needRehash(string $hash): bool { $length = strlen($hash); if ($length < 34 || $hash[0] !== '$') { return true; } // check if hash is not portable if ($length !== 34 || $hash[2] !== '$') { if (str_contains('PH', $hash[1])) { return true; } // we follow standard // 2[abxy] -> bcrypt ($2a$, $2b$, $2x$, $2y$) // argon2i -> argon 2 i ($argon2i$) // argon2id -> argon 2 id ($argon2id$) // argon2ds -> argon 2 ds ($argon2ds$) // argon2d -> argon 2 d ($argon2d$) return preg_match('~^\$(2[abxy]?|argon2(?:id?|ds?))\$~', $hash, $matches) && password_needs_rehash($hash, $matches[1]); } return ! self::isPortable($hash); } /** * Hash the password * * @param string $password * @param bool $portable * @param int $cost * @param string $algo * @return string */ public static function hash( string $password, bool $portable = false, int $cost = self::DEFAULT_COST, string $algo = PASSWORD_DEFAULT ): string { // set cost $cost = max(4, min(31, $cost)); if ($portable) { $maxRetry = 10; do { $output = '$P$'; $output .= self::ITO_A64[min($cost + 5, 30)]; $output .= self::encode64(self::randomBytes(6), 6); // output should 12 $hash = self::crypt($password, $output); if (strlen($hash) === 34) { return $hash; } } while (--$maxRetry > 0); // if no succeed, force to @use password_hash() } if (!self::$passwordAlgorithms) { self::$passwordAlgorithms = []; foreach (password_algos() as $algorithm) { self::$passwordAlgorithms[strtolower($algorithm)] = $algorithm; } } $algo = strtolower(trim($algo)); $algo = self::$passwordAlgorithms[$algo] ?? PASSWORD_DEFAULT; return password_hash($password, $algo, ['cost' => $cost]); } }