Best-Case PHP Password Hashing with Argon2, bcrypt, Salt 'n' Pepper!
...or: The Phattest, Dopest Hashes Ever.
At present, PHP versions 7.3 and up provide the facility to use the much lauded Argon2id hashing algorithm with
From the official documentation for
- PASSWORD_DEFAULT - Use the bcrypt algorithm (default as of PHP 5.5.0). Note that this constant is designed to change over time as new and stronger algorithms are added to PHP. For that reason, the length of the result from using this identifier can change over time. Therefore, it is recommended to store the result in a database column that can expand beyond 60 characters (255 characters would be a good choice).
- PASSWORD_BCRYPT - Use the CRYPT_BLOWFISH algorithm to create the hash. This will produce a standard
crypt() compatible hash using the "$2y$" identifier. The result will always be a 60 character string, or false on failure.- PASSWORD_ARGON2I - Use the Argon2i hashing algorithm to create the hash. This algorithm is only available if PHP has been compiled with Argon2 support.
- PASSWORD_ARGON2ID - Use the Argon2id hashing algorithm to create the hash. This algorithm is only available if PHP has been compiled with Argon2 support.
bcrypt
I have seen it suggested in myriad threads and articles that bcrypt has a maximum 72 or 56 byte or character limit and that one should be cautious of plaintexts truncating if they exceed this limit. I took it upon myself to do a little experimentation with PHP v.7.4.3's implementation and at first it not seem to be the case. By continuously adding characters to the end of a static string I was successfully able to produce different hashes for passphrases well in excess of 72 characters.
<?php $cleartext = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; print("\n".strlen($cleartext)." chars\n"); // 62 chars print(password_hash($cleartext, PASSWORD_BCRYPT)); // $2y$10$Dvkcz7EuXomYp4l0mlvT3.7.iR8lY/haen24/0unZ04HPozxJKsOu $cleartext = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; print("\n".strlen($cleartext)." chars\n"); // 72 chars print(password_hash($cleartext, PASSWORD_BCRYPT)); // $2y$10$knPetj8DXKLL0fQ59Er.B.oThZVRS4.6gxDxiS6TZoawkYmNBLylm $cleartext = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567890123456789'; print("\n".strlen($cleartext)." chars\n"); // 82 chars print(password_hash($cleartext, PASSWORD_BCRYPT)); // $2y$10$Po0r9n4nvn.xa47vd71H9ux4sVlZRIBPfUpkFH.P9sh81AjfuHd1a $cleartext = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678901234567890123456789'; print("\n".strlen($cleartext)." chars\n"); // 92 chars print(password_hash($cleartext, PASSWORD_BCRYPT)); // $2y$10$jOWGAlQXumlM8hhrYi0CYul2tDgyNUr7BUAL6OVTgRy8wdopLpKve
Imagine my embarrassment when I realized I might be getting different hashes by dint of the automatic (as prescribed by the documentation) salting. Overriding the salt, my folly crystalized:
<?php $cleartext = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; print("\n".strlen($cleartext)." chars\n"); // 62 chars print(password_hash($cleartext, PASSWORD_BCRYPT, ['salt' => '0123456789012345678901'])); // $2y$10$012345678901234567890ud2EECbHu.rRFwwnefTITxbknYm62FsO $cleartext = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; print("\n".strlen($cleartext)." chars\n"); // 72 chars print(password_hash($cleartext, PASSWORD_BCRYPT, ['salt' => '0123456789012345678901'])); // $2y$10$012345678901234567890uLrcC7snclVwFuX6TBHjVi4bTVNDpAaG $cleartext = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567890123456789'; print("\n".strlen($cleartext)." chars\n"); // 82 chars print(password_hash($cleartext, PASSWORD_BCRYPT, ['salt' => '0123456789012345678901'])); // $2y$10$012345678901234567890uLrcC7snclVwFuX6TBHjVi4bTVNDpAaG $cleartext = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678901234567890123456789'; print("\n".strlen($cleartext)." chars\n"); // 92 chars print(password_hash($cleartext, PASSWORD_BCRYPT, ['salt' => '0123456789012345678901'])); // $2y$10$012345678901234567890uLrcC7snclVwFuX6TBHjVi4bTVNDpAaG
It is therefore important to bear in mind that any more than 72 characters - perhaps even less when dealing with unicode characters outside the initial ASCII compatible block - will be truncated. This is particularly relevant in cases where you are concatenating salts or peppers to the passphrase.
Argon2
It is recommended to use Argon2id instead of Argon2i wherever possible as it adds side channel resiliency to the Argon2i variant's GPU cracking resistance. In the interest of making as-portable-as-possible code, I have eschewed the PHP Sodium library (the functions of which include possibly more secure and exotic alternatives) and whipped up a quick and dirty little function that checks if the default algorithm is still bcrypt and then, if available, bumps up to the Argon2id variant. I chose to hard code the computational complexity parameters to the values of the default constants on this server's environment because they exceeded the parameters suggested on the Argon2 wikipedia article as well as the recommendations put forward by OWASP and that suggests to me that they are a reasonable floor to anchor to in case the code some day finds shelter in older or more resource competitive environments that come out of the box with weaker settings - and also to make it easier for you to twiddle all the knobs without having to refer to the documentation (which is, as always, excellent).
function user_authentication_hash($plaintext, $pepper='') { $pass_algo = PASSWORD_DEFAULT; // PASSWORD_BCRYPT [default per PHP 5.5-8] | PASSWORD_ARGON2I | PASSWORD_ARGON2ID $pass_options = array('cost' => 15); // Default PASSWORD_BCRYPT cost 10 $pass_supported = password_algos(); if(PASSWORD_DEFAULT == PASSWORD_BCRYPT) // Bump up from BCRYPT (PHP 7.2+), unless a better algo than Argon2 becomes default { if(in_array('argon2id', $pass_supported)) { $pass_algo = PASSWORD_ARGON2ID; $pass_options = array( // https://en.wikipedia.org/wiki/Argon2#Recommended_minimum_parameters 'memory_cost' => 65536, // default PASSWORD_ARGON2_DEFAULT_MEMORY_COST == 65536 KiB (64MB) 'time_cost' => 4, // default PASSWORD_ARGON2_DEFAULT_TIME_COST == 4 'threads' => 1 // default PASSWORD_ARGON2_DEFAULT_THREADS == 1 ); } elseif(in_array('argon2i', $pass_supported)) { $pass_algo = PASSWORD_ARGON2I; $pass_options = array( 'memory_cost' => 65536, 'time_cost' => 4, 'threads' => 1 ); } } return password_hash($plaintext.$pepper, $pass_algo, $pass_options); }
Constants
It should be noted that in earlier versions of PHP the
<?php print(PASSWORD_BCRYPT."\n\n"); print_r(password_algos()); 2y Array ( [0] => 2y [1] => argon2i [2] => argon2id )
Futureproofing
Being a
$bool = password_verify($plain_text.$pepper, $stored_hash);
Salt
My favourite part of the
Pepper, Source Code
According to OWASP, a pepper can be even better implemented as the key to a symmetric encryption scheme that further obfuscates one's hashes rather than concatenating a simple string to the cleartext. Cribbing from Harry Gogonas' Medium article "Symmetric Encryption in PHP" (with deference and appreciation for his clean-cut implementation) I drew up a little class for portability:
class UserAuthentication
{
// https://foxpa.ws/php-password-hashing-and-peppering
// This class grabs a symmetric encryption key from $_GLOBALS['pepper'] to permit static instantiation
public static function hash($cleartext)
{
$pass_algo = PASSWORD_DEFAULT; // PASSWORD_BCRYPT [default per PHP 5.5-8] | PASSWORD_ARGON2I | PASSWORD_ARGON2ID
$pass_options = array('cost' => 15); // Default PASSWORD_BCRYPT cost 10
$pass_supported = password_algos();
if(PASSWORD_DEFAULT == PASSWORD_BCRYPT) // Bump up from BCRYPT (PHP 7.2+), unless a better algo than Argon2 becomes default
{
if(in_array('argon2id', $pass_supported))
{
$pass_algo = PASSWORD_ARGON2ID;
$pass_options = array( // https://en.wikipedia.org/wiki/Argon2#Recommended_minimum_parameters
'memory_cost' => 65536, // default PASSWORD_ARGON2_DEFAULT_MEMORY_COST == 65536 KiB (64MB)
'time_cost' => 4, // default PASSWORD_ARGON2_DEFAULT_TIME_COST == 4
'threads' => 1 // default PASSWORD_ARGON2_DEFAULT_THREADS == 1
);
}
elseif(in_array('argon2i', $pass_supported))
{
$pass_algo = PASSWORD_ARGON2I;
$pass_options = array(
'memory_cost' => 65536,
'time_cost' => 4,
'threads' => 1
);
}
}
return password_hash($cleartext, $pass_algo, $pass_options);
}
public static function encrypt($cleartext)
{
$key = $_GLOBALS['pepper'];
$length = openssl_cipher_iv_length('AES-256-CBC');
$iv = openssl_random_pseudo_bytes($length);
$encrypted = openssl_encrypt($cleartext, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
$cyphertext = base64_encode($encrypted) . '|' . base64_encode($iv);
return $cyphertext;
}
public static function decrypt($input)
{
$key = $_GLOBALS['pepper'];
list($cyphertext, $iv) = explode('|', $input);
$iv = base64_decode($iv);
$cleartext = openssl_decrypt($cyphertext, 'AES-256-CBC', $key, 0, $iv);
return $cleartext;
}
public static function write($cleartext) // Prepare a hash for insertion into users table
{
return self::encrypt(self::hash($cleartext));
}
public static function read($cyphertext) // Resolve a hash from symmetrically encrypted cyphertext
{
return self::decrypt($cyphertext);
}
public static function verify($cleartext, $cyphertext) // Verifies raw user input, $cyphertext is sourced directly from users table
{
return password_verify($cleartext, self::read($cyphertext));
}
}
Comments
There are no comments for this item.