adm_program/system/classes/PasswordUtils.php
<?php
use Admidio\Exception;
/**
* @brief This class provides static functions for different tasks for passwords and hashing
*
* Functions:
* hash() hash the given password with the given options
* verify() verify if the given password belongs to the given hash
* needsRehash() checks if the given hash is generated from the given options
* passwordInfo() provides infos about the given password (length, number, lowerCase, upperCase, symbol)
* hashInfo() provides infos about the given hash (Algorithm & Options, PRIVATE/PORTABLE_HASH, MD5, UNKNOWN)
* passwordStrength() shows the strength of the given password
* costBenchmark() run a benchmark to get the best fitting cost value
*
* @copyright The Admidio Team
* @see https://www.admidio.org/
* @license https://www.gnu.org/licenses/gpl-2.0.html GNU General Public License v2.0 only
*/
final class PasswordUtils
{
public const HASH_ALGORITHM_DEFAULT = 'DEFAULT';
public const HASH_ALGORITHM_ARGON2ID = 'ARGON2ID';
public const HASH_ALGORITHM_ARGON2I = 'ARGON2I';
public const HASH_ALGORITHM_BCRYPT = 'BCRYPT';
public const HASH_ALGORITHM_SHA512 = 'SHA512';
public const HASH_COST_BCRYPT_DEFAULT = PASSWORD_BCRYPT_DEFAULT_COST;
public const HASH_COST_BCRYPT_MIN = 8;
public const HASH_COST_BCRYPT_MAX = 31;
public const HASH_COST_SHA512_DEFAULT = 100000;
public const HASH_COST_SHA512_MIN = 25000;
public const HASH_COST_SHA512_MAX = 999999999;
public const HASH_INDICATOR_ARGON2ID = '$argon2id$';
public const HASH_INDICATOR_ARGON2I = '$argon2i$';
public const HASH_INDICATOR_BCRYPT = '$2y$';
public const HASH_INDICATOR_SHA512 = '$6$';
public const HASH_INDICATOR_PORTABLE = '$P$';
/**
* Run a benchmark to get the best fitting cost value.
* @param string $algorithm The algorithm to test
* @param array<string,int> $options The options to test
* @param float $maxTime The maximum time the hashing process should take in seconds
* @param string $password The password to test
* @return array<string,int|float|array<string,int>> Returns an array with the maximum tested cost with the required time
* @throws Exception
*/
public static function costBenchmark(string $algorithm = self::HASH_ALGORITHM_DEFAULT, array $options = array(), float $maxTime = 0.2, string $password = '123456abcdef_-#:'): ?array
{
global $gLogger;
$options = self::getPreparedOptions($algorithm, $options);
if ($algorithm === self::HASH_ALGORITHM_SHA512) {
$maxCost = self::HASH_COST_SHA512_MAX;
} elseif ($algorithm === self::HASH_ALGORITHM_BCRYPT || ($algorithm === self::HASH_ALGORITHM_DEFAULT && PASSWORD_DEFAULT === PASSWORD_BCRYPT)) {
$maxCost = self::HASH_COST_BCRYPT_MAX;
} else {
return array('options' => $options, 'time' => null);
}
$result = null;
// increase the cost value until the hashing time reaches the maximum configured time
do {
$start = microtime(true);
self::hash($password, $algorithm, $options);
$end = microtime(true);
$time = $end - $start;
if ($result === null || $time <= $maxTime) {
$result = array('options' => $options, 'time' => $time);
}
if ($algorithm === self::HASH_ALGORITHM_SHA512) {
$options['cost'] *= 2;
} else {
$options['cost'] += 1;
}
} while ($time <= $maxTime && $options['cost'] <= $maxCost);
$gLogger->notice('Benchmark: Password-hashing result.', $result);
return $result;
}
/**
* Hash the given password with the given options. The default algorithm uses the password_* methods,
* otherwise the builtin helper for SHA-512 crypt hashes from the operating system. Minimum cost is 10.
* @param string $password The password string
* @param string $algorithm The hash-algorithm method. Possible values are 'DEFAULT', 'ARGON2ID', 'ARGON2I', 'BCRYPT' or 'SHA512'.
* @param array<string,int> $options The hash-options array
* @return string|false Returns the hashed password or false if an error occurs
* @throws Exception
*/
public static function hash(string $password, string $algorithm = self::HASH_ALGORITHM_DEFAULT, array $options = array())
{
$options = self::getPreparedOptions($algorithm, $options);
switch ($algorithm) {
case self::HASH_ALGORITHM_DEFAULT:
$algorithmPhpConstant = PASSWORD_DEFAULT;
break;
case self::HASH_ALGORITHM_ARGON2ID:
$algorithmPhpConstant = PASSWORD_ARGON2ID;
break;
case self::HASH_ALGORITHM_ARGON2I:
$algorithmPhpConstant = PASSWORD_ARGON2I;
break;
case self::HASH_ALGORITHM_BCRYPT:
$algorithmPhpConstant = PASSWORD_BCRYPT;
break;
case self::HASH_ALGORITHM_SHA512:
$salt = SecurityUtils::getRandomString(8, '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ./');
return crypt($password, self::HASH_INDICATOR_SHA512 . 'rounds=' . $options['cost'] . '$' . $salt . '$');
default:
$algorithmPhpConstant = PASSWORD_DEFAULT;
}
return password_hash($password, $algorithmPhpConstant, $options);
}
/**
* Prepares the options values
* @param string $algorithm The hash-algorithm method. Possible values are 'DEFAULT', 'ARGON2ID', 'ARGON2I', 'BCRYPT' or 'SHA512'.
* @param array<string,int> $options The hash-options array
* @return array<string,int>
* @throws Exception
*/
private static function getPreparedOptions(string $algorithm, array $options): array
{
if ($algorithm === self::HASH_ALGORITHM_SHA512) {
$defaultCost = self::HASH_COST_SHA512_DEFAULT;
$minCost = self::HASH_COST_SHA512_MIN;
} elseif ($algorithm === self::HASH_ALGORITHM_BCRYPT || ($algorithm === self::HASH_ALGORITHM_DEFAULT && PASSWORD_DEFAULT === PASSWORD_BCRYPT)) {
$defaultCost = self::HASH_COST_BCRYPT_DEFAULT;
$minCost = self::HASH_COST_BCRYPT_MIN;
} else {
$options['cost'] = null;
return $options;
}
if (!array_key_exists('cost', $options) || !is_int($options['cost'])) {
global $gSettingsManager;
if (isset($gSettingsManager) && $gSettingsManager->has('system_hashing_cost')) {
$options['cost'] = $gSettingsManager->getInt('system_hashing_cost');
} else {
$options['cost'] = $defaultCost;
}
} elseif ($options['cost'] < $minCost) { // https://paragonie.com/blog/2016/02/how-safely-store-password-in-2016
$options['cost'] = $minCost;
}
return $options;
}
/**
* Provides infos about the given hash (Algorithm & Options, PRIVATE/PORTABLE_HASH, MD5, UNKNOWN)
* @param string $hash The hash you want the get infos about
* @return string|array<string,mixed>|null Returns an array or string with infos about the given hash
*/
public static function hashInfo(string $hash)
{
if (str_starts_with($hash, self::HASH_INDICATOR_ARGON2ID) || str_starts_with($hash, self::HASH_INDICATOR_ARGON2I) || str_starts_with($hash, self::HASH_INDICATOR_BCRYPT)) {
return password_get_info($hash);
} elseif (str_starts_with($hash, self::HASH_INDICATOR_SHA512)) {
return 'SHA512';
} elseif (str_starts_with($hash, self::HASH_INDICATOR_PORTABLE)) {
return 'PRIVATE/PORTABLE_HASH';
}
// MD5 Hashes are 32 chars long and consists out of HEX values (digits and a-f)
elseif (preg_match('/^[\dA-Fa-f]{32}$/', $hash)) {
return 'MD5';
}
return 'UNKNOWN';
}
/**
* Checks if the given hash is generated from the given options. The default algorithm uses the
* password_* methods, otherwise the builtin helper for SHA-512 crypt hashes from the operating system.
* @param string $hash The hash string that should be checked
* @param string $algorithm The hash-algorithm the hash should match to. Possible values are 'DEFAULT', 'ARGON2ID', 'ARGON2I', 'BCRYPT' or 'SHA512'.
* @param array<string,int> $options The hash-options the hash should match to
* @return bool Returns false if the hash match the given options and false if not
* @throws Exception
*/
public static function needsRehash(string $hash, string $algorithm = self::HASH_ALGORITHM_DEFAULT, array $options = array()): bool
{
$options = self::getPreparedOptions($algorithm, $options);
if ($algorithm === self::HASH_ALGORITHM_DEFAULT) {
$algorithmPhpConstant = PASSWORD_DEFAULT;
} elseif ($algorithm === self::HASH_ALGORITHM_ARGON2ID && str_starts_with($hash, self::HASH_INDICATOR_ARGON2ID)) {
$algorithmPhpConstant = PASSWORD_ARGON2ID;
} elseif ($algorithm === self::HASH_ALGORITHM_ARGON2I && str_starts_with($hash, self::HASH_INDICATOR_ARGON2I)) {
$algorithmPhpConstant = PASSWORD_ARGON2I;
} elseif ($algorithm === self::HASH_ALGORITHM_BCRYPT && str_starts_with($hash, self::HASH_INDICATOR_BCRYPT)) {
$algorithmPhpConstant = PASSWORD_BCRYPT;
} elseif ($algorithm === self::HASH_ALGORITHM_SHA512 && str_starts_with($hash, self::HASH_INDICATOR_SHA512)) {
$hashParts = explode('$', $hash);
$cost = (int) substr($hashParts[2], 7);
return $cost !== $options['cost'];
} else {
return true;
}
return password_needs_rehash($hash, $algorithmPhpConstant, $options);
}
/**
* Provides infos about the given password (length, number, lowerCase, upperCase, symbol)
* @param string $password The password you want the get infos about
* @return array<string,int|bool> Returns an array with infos about the given password
*/
public static function passwordInfo(string $password): array
{
$passwordInfo = array(
'length' => strlen($password),
'number' => false,
'lowerCase' => false,
'upperCase' => false,
'symbol' => false
);
if (preg_match('/\d/', $password) === 1) {
$passwordInfo['number'] = true;
}
if (preg_match('/[a-z]/', $password) === 1) {
$passwordInfo['lowerCase'] = true;
}
if (preg_match('/[A-Z]/', $password) === 1) {
$passwordInfo['upperCase'] = true;
}
if (preg_match('/\W/', $password) === 1 || str_contains($password, '_')) { // Note: \W = ![\da-zA-Z_]
$passwordInfo['symbol'] = true;
}
return $passwordInfo;
}
/**
* Calculates the strength of a given password from 0-4.
* @param string $password The password to check
* @param array<int,string> $userData An array of strings for dictionary attacks
* @return int Returns the score of the password
*/
public static function passwordStrength(string $password, array $userData = array()): int
{
$zxcvbn = new ZxcvbnPhp\Zxcvbn();
$strength = $zxcvbn->passwordStrength($password, $userData);
return $strength['score'];
}
/**
* Verify if the given password belongs to the given hash
* @param string $password The password string to check
* @param string $hash The hash string to check
* @return bool Returns true if the password belongs to the hash and false if not
*/
public static function verify(string $password, string $hash): bool
{
if (str_starts_with($hash, self::HASH_INDICATOR_ARGON2ID) || str_starts_with($hash, self::HASH_INDICATOR_ARGON2I) || str_starts_with($hash, self::HASH_INDICATOR_BCRYPT)) {
return password_verify($password, $hash);
} elseif (str_starts_with($hash, self::HASH_INDICATOR_SHA512)) {
$passwordHash = crypt($password, $hash);
return hash_equals($passwordHash, $hash);
}
// MD5 Hashes are 32 chars long and consists out of HEX values (digits and a-f)
elseif (preg_match('/^[\dA-Fa-f]{32}$/', $hash)) {
return md5($password) === $hash;
}
return false;
}
}