=^.^=

Best-Case PHP Password Hashing with Argon2, bcrypt, Salt 'n' Pepper!

karma

...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 password_hash() in addition to the PASSWORD_DEFAULT constant which leaves the farm pointing at PASSWORD_BCRYPT. The official documentation for password_hash() encourages using PASSWORD_DEFAULT as it is expected to change as the landscape of hashing algorithms evolves and PHP's support for different, better algorithms naturally expands. bcrypt (based on the Blowfish algorithm) is still considered quite hardy, yet it has remained the default option since PHP's major version 5. Meanwhile, Argon2i support was added a few years ago in PHP version 7.2 followed by Argon2id in 7.3. Though it has not yet become the PASSWORD_DEFAULT as of major version 8, it is newer (c. 2015 vs c. 1999) and widely held to be more secure.

From the official documentation for password_hash() on php.net regarding one's options as of major version 8:

  • 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 PASSWORD_ constants represented integer values; this has changed - for example bcrypt is represented by the token 2y as demonstrated by print()ing PASSWORD_BCRYPT and its element's value in the array returned by password_algos():

<?php
print(PASSWORD_BCRYPT."\n\n");
print_r(password_algos());

2y

Array
(
    [0] => 2y
    [1] => argon2i
    [2] => argon2id
)

Futureproofing

Being a crypt() formatted hash regardless of your choice of algorithm, you may implement later upconversions as the algorithmic landscape changes by leveraging password_needs_rehash(). Algorithmically agnostic validation of passphrases is handled by password_verify() thus:

$bool = password_verify($plain_text.$pepper, $stored_hash);

Salt

My favourite part of the crypt() interface, aside from identifying the appropriate algorithm and parameters inline with the cyphertext, is the automatic inclusion of a randomized salt. You may also wish to investigate the use of an additional string concatenation included from a disparate source, i.e. a variable or algorithm hidden in a file outside of the DocumentRoot or perhaps a separate/remote database, to accomplish salting's savory counterpart: peppering. While salts are stored inline with the cryptographic product and are therefore exposed at the same time as the cyphertext during, for example, a database breach (i.e by way of SQL injection), a secret value stored separately - even a single variable reused - increases the scope an attacker may have to compromise to enable cryptographic attacks and further negates the utility of precomputed rainbow tables.

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.