Skip to content

Instantly share code, notes, and snippets.

@ArrayIterator
Last active April 6, 2024 23:25
Show Gist options
  • Save ArrayIterator/4daf85a152a83085815c3d44c2adc614 to your computer and use it in GitHub Desktop.
Save ArrayIterator/4daf85a152a83085815c3d44c2adc614 to your computer and use it in GitHub Desktop.
Password Hashing Library with OpenWall PasswordHash Compat
<?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]);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment