diff --git a/src/Appwrite/Auth/Auth.php b/src/Appwrite/Auth/Auth.php index 3cefbb1893..eca8a99800 100644 --- a/src/Appwrite/Auth/Auth.php +++ b/src/Appwrite/Auth/Auth.php @@ -2,6 +2,10 @@ namespace Appwrite\Auth; +use Appwrite\Auth\Hash\BCrypt; +use Appwrite\Auth\Hash\MD5; +use Appwrite\Auth\Hash\PHPass; +use Appwrite\Auth\Hash\SCrypt; use Utopia\Database\Document; use Utopia\Database\Validator\Authorization; @@ -132,19 +136,30 @@ class Auth * * @param string $string * @param string $algo hashing algorithm to use + * @param string $options algo-specific options * * @return bool|string|null */ - public static function passwordHash(string $string, string $algo) + public static function passwordHash(string $string, string $algo, mixed $options = []) { + // TODO: Abstract, somehow. switch ($algo) { case 'bcrypt': - return \password_hash($string, PASSWORD_BCRYPT, array('cost' => 8)); + $hasher = new BCrypt($options); + $hash = $hasher->hash($string); + return $hash; case 'scrypt': - return \scrypt($string, "", 8, 1, 1, 64); - throw new Error('Hashing algorithm scrypt not supported yet.'); + $hasher = new SCrypt($options); + $hash = $hasher->hash($string); + return $hash; case 'md5': - return \md5($string); + $hasher = new MD5($options); + $hash = $hasher->hash($string); + return $hash; + case 'phpass': + $hahser = new PHPass(8, FALSE); + $hash = $hahser->hash($string); + return $hash; } return null; @@ -156,18 +171,32 @@ class Auth * @param string $plain * @param string $hash * @param string $algo hashing algorithm used to hash + * @param string $options algo-specific options * * @return bool */ - public static function passwordVerify(string $plain, string $hash, string $algo) + public static function passwordVerify(string $plain, string $hash, string $algo, mixed $options = []) { + + // TODO: Abstract, somehow. switch ($algo) { case 'bcrypt': - return \password_verify($plain, $hash); + $hasher = new BCrypt($options); + $verify = $hasher->verify($plain, $hash); + return $verify; case 'scrypt': - return \scrypt($plain, "", 8, 1, 1, 64) === $hash; + $hasher = new SCrypt($options ?? [ 'cost_cpu' => 8, 'cost_memory' => 14, 'cost_parallel' => 1, 'length' => 64 ]); + $verify = $hasher->verify($plain, $hash); + return $verify; case 'md5': - return \md5($plain) === $hash; + $hasher = new MD5($options ?? []); + $verify = $hasher->verify($plain, $hash); + return $verify; + case 'phpass': + // TODO: Support options + $hahser = new PHPass(8, FALSE); + $verify = $hahser->verify($plain, $hash); + return $verify; } return false; diff --git a/src/Appwrite/Auth/Hash.php b/src/Appwrite/Auth/Hash.php new file mode 100644 index 0000000000..f107fac41d --- /dev/null +++ b/src/Appwrite/Auth/Hash.php @@ -0,0 +1,59 @@ +setOptions($options); + } + + /** + * Set hashing algo options + * + * @param mixed $options Hashing-algo specific options + */ + public function setOptions(mixed $options): self { + $this->options = \array_merge([], $this->getDefaultOptions(), $options); + return $this; + } + + /** + * Get hashing algo options + * + * @return mixed $options Hashing-algo specific options + */ + public function getOptions(): mixed { + return $this->options; + } + + /** + * @param string $password Input password to hash + * + * @return string hash + */ + abstract public function hash(string $password): string; + + /** + * @param string $password Input password to validate + * @param string $hash Hash to verify password against + * + * @return boolean true if password matches hash + */ + abstract public function verify(string $password, string $hash): bool; + + /** + * Get default options for specific hashing algo + * + * @return mixed options named array + */ + abstract public function getDefaultOptions(): mixed; +} diff --git a/src/Appwrite/Auth/Hash/BCrypt.php b/src/Appwrite/Auth/Hash/BCrypt.php new file mode 100644 index 0000000000..297be71578 --- /dev/null +++ b/src/Appwrite/Auth/Hash/BCrypt.php @@ -0,0 +1,43 @@ +getOptions()); + } + + /** + * @param string $password Input password to validate + * @param string $hash Hash to verify password against + * + * @return boolean true if password matches hash + */ + public function verify(string $password, string $hash): bool { + return \password_verify($password, $hash); + } + + /** + * Get default options for specific hashing algo + * + * @return mixed options named array + */ + public function getDefaultOptions(): mixed { + return [ 'cost' => 8 ]; + } +} \ No newline at end of file diff --git a/src/Appwrite/Auth/Hash/MD5.php b/src/Appwrite/Auth/Hash/MD5.php new file mode 100644 index 0000000000..617537ac3c --- /dev/null +++ b/src/Appwrite/Auth/Hash/MD5.php @@ -0,0 +1,41 @@ + in 2004-2017 and placed in + * the public domain. Revised in subsequent years, still public domain. + * There's absolutely no warranty. + * The homepage URL for the source framework is: http://www.openwall.com/phpass/ + * Please be sure to update the Version line if you edit this file in any way. + * It is suggested that you leave the main version number intact, but indicate + * your project name (after the slash) and add your own revision information. + * Please do not change the "private" password hashing method implemented in + * here, thereby making your hashes incompatible. However, if you must, please + * change the hash type identifier (the "$P$") to something different. + * Obviously, since this code is in the public domain, the above are not + * requirements (there can be none), but merely suggestions. + * + * @author Solar Designer + * @copyright Copyright (C) 2017 All rights reserved. + * @license http://www.opensource.org/licenses/mit-license.html MIT License; see LICENSE.txt + */ + +namespace Appwrite\Auth\Hash; + +/** + * PasswordHash class is a portable password hashing framework for use in PHP applications. + * + * @since 0.1 + */ +class PHPass +{ + /** + * Alphabet used in itoa64 conversions. + * + * @var string + * @since 0.1.0 + */ + protected $itoa64 = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + + /** + * The Logarithmic cost value used when generating hash values indicating the number of rounds used to generate hashes + * + * @var integer + * @since 0.1.0 + */ + public $iteration_count_log2 = 12; + + /** + * The portable_hashes + * + * @var string + * @since 0.1.0 + */ + public $portable_hashes; + + /** + * The cached random state + * + * @var string + * @since 0.1.0 + */ + protected $random_state; + + /** + * Constructor + * + * @param int $iteration_count_log2 Logarithmic cost value used when generating hash values + * @param string $portable_hashes + * + * @since 0.5.0 + */ + public function __construct($iteration_count_log2, $portable_hashes) + { + if ($iteration_count_log2 < 10) { + // The minimum Logarithmic cost value + $iteration_count_log2 = 10; + } else { + if ($iteration_count_log2 > 32) { + // The maximum Logarithmic cost value + $iteration_count_log2 = 32; + } + } + $this->iteration_count_log2 = $iteration_count_log2; + $this->portable_hashes = $portable_hashes; + $this->random_state = microtime(); + if (function_exists('getmypid')) { + $this->random_state .= getmypid(); + } + } + + /** + * @param String $password + * + * @return String $hash + * @since 0.1.0 + */ + public function hash($password) + { + $random = ''; + if (CRYPT_BLOWFISH === 1 && !$this->portable_hashes) { + $random = $this->get_random_bytes(16); + $hash = crypt($password, $this->gensalt_blowfish($random)); + if (strlen($hash) === 60) { + return $hash; + } + } + if (strlen($random) < 6) { + $random = $this->get_random_bytes(6); + } + $hash = $this->crypt_private($password, $this->gensalt_private($random)); + if (strlen($hash) === 34) { + return $hash; + } + + /** + * Returning '*' on error is safe here, but would _not_ be safe + * in a crypt(3)-like function used _both_ for generating new + * hashes and for validating passwords against existing hashes. + */ + return '*'; + } + + /** + * @param String $password + * @param String $stored_hash + * + * @return boolean + * @since 0.1.0 + */ + public function verify($password, $stored_hash) + { + $hash = $this->crypt_private($password, $stored_hash); + if ($hash[0] === '*') { + $hash = crypt($password, $stored_hash); + } + + /** + * This is not constant-time. In order to keep the code simple, + * for timing safety we currently rely on the salts being + * unpredictable, which they are at least in the non-fallback + * cases (that is, when we use /dev/urandom and bcrypt). + */ + return $hash === $stored_hash; + } + + /** + * A backwards compatible constructor + * + * @param int $iteration_count_log2 Logarithmic cost value used when generating hash values + * @param string $portable_hashes + * + * @since 0.1.0 + */ + public function PasswordHash($iteration_count_log2, $portable_hashes) + { + self::__construct($iteration_count_log2, $portable_hashes); + } + + /** + * @param int $count + * + * @return String $output + * @since 0.1.0 + * @throws InvalidArgumentException Thows an InvalidArgumentException if the $count parameter is not a positive integer. + */ + protected function get_random_bytes($count) + { + if (!is_int($count) || $count < 1) { + throw new InvalidArgumentException('Argument count must be a positive integer'); + } + $output = ''; + if (@is_readable('/dev/urandom') && ($fh = @fopen('/dev/urandom', 'rb'))) { + $output = fread($fh, $count); + fclose($fh); + } + + if (strlen($output) < $count) { + $output = ''; + + for ($i = 0; $i < $count; $i += 16) { + $this->random_state = md5(microtime() . $this->random_state); + $output .= md5($this->random_state, TRUE); + } + + $output = substr($output, 0, $count); + } + + return $output; + } + + /** + * @param String $input + * @param int $count + * + * @return String $output + * @since 0.1.0 + * @throws InvalidArgumentException Thows an InvalidArgumentException if the $count parameter is not a positive integer. + */ + protected function encode64($input, $count) + { + if (!is_int($count) || $count < 1) { + throw new InvalidArgumentException('Argument count must be a positive integer'); + } + $output = ''; + $i = 0; + do { + $value = ord($input[$i++]); + $output .= $this->itoa64[$value & 0x3f]; + if ($i < $count) { + $value |= ord($input[$i]) << 8; + } + $output .= $this->itoa64[($value >> 6) & 0x3f]; + if ($i++ >= $count) { + break; + } + if ($i < $count) { + $value |= ord($input[$i]) << 16; + } + $output .= $this->itoa64[($value >> 12) & 0x3f]; + if ($i++ >= $count) { + break; + } + $output .= $this->itoa64[($value >> 18) & 0x3f]; + } while ($i < $count); + + return $output; + } + + /** + * @param String $input + * + * @return String $output + * @since 0.1.0 + */ + private function gensalt_private($input) + { + $output = '$P$'; + $output .= $this->itoa64[min($this->iteration_count_log2 + ((PHP_VERSION >= '5') ? 5 : 3), 30)]; + $output .= $this->encode64($input, 6); + + return $output; + } + + /** + * @param String $password + * @param String $setting + * + * @return String $output + * @since 0.1.0 + */ + private function crypt_private($password, $setting) + { + $output = '*0'; + if (substr($setting, 0, 2) === $output) { + $output = '*1'; + } + $id = substr($setting, 0, 3); + // We use "$P$", phpBB3 uses "$H$" for the same thing + if ($id !== '$P$' && $id !== '$H$') { + return $output; + } + $count_log2 = strpos($this->itoa64, $setting[3]); + if ($count_log2 < 7 || $count_log2 > 30) { + return $output; + } + $count = 1 << $count_log2; + $salt = substr($setting, 4, 8); + if (strlen($salt) !== 8) { + return $output; + } + /** + * We were kind of forced to use MD5 here since it's the only + * cryptographic primitive that was available in all versions of PHP + * in use. To implement our own low-level crypto in PHP + * would have result in much worse performance and + * consequently in lower iteration counts and hashes that are + * quicker to crack (by non-PHP code). + */ + $hash = md5($salt . $password, TRUE); + do { + $hash = md5($hash . $password, TRUE); + } while (--$count); + $output = substr($setting, 0, 12); + $output .= $this->encode64($hash, 16); + + return $output; + } + + /** + * @param String $input + * + * @return String $output + * @since 0.1.0 + */ + private function gensalt_blowfish($input) + { + /** + * This one needs to use a different order of characters and a + * different encoding scheme from the one in encode64() above. + * We care because the last character in our encoded string will + * only represent 2 bits. While two known implementations of + * bcrypt will happily accept and correct a salt string which + * has the 4 unused bits set to non-zero, we do not want to take + * chances and we also do not want to waste an additional byte + * of entropy. + */ + $itoa64 = './ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + $output = '$2a$'; + $output .= chr(ord('0') + $this->iteration_count_log2 / 10); + $output .= chr(ord('0') + $this->iteration_count_log2 % 10); + $output .= '$'; + $i = 0; + do { + $c1 = ord($input[$i++]); + $output .= $itoa64[$c1 >> 2]; + $c1 = ($c1 & 0x03) << 4; + if ($i >= 16) { + $output .= $itoa64[$c1]; + break; + } + $c2 = ord($input[$i++]); + $c1 |= $c2 >> 4; + $output .= $itoa64[$c1]; + $c1 = ($c2 & 0x0f) << 2; + $c2 = ord($input[$i++]); + $c1 |= $c2 >> 6; + $output .= $itoa64[$c1]; + $output .= $itoa64[$c2 & 0x3f]; + } while (1); + + return $output; + } +} \ No newline at end of file diff --git a/src/Appwrite/Auth/Hash/SCrypt.php b/src/Appwrite/Auth/Hash/SCrypt.php new file mode 100644 index 0000000000..9267090725 --- /dev/null +++ b/src/Appwrite/Auth/Hash/SCrypt.php @@ -0,0 +1,48 @@ +getOptions(); + + return \scrypt($password, $options['salt'] ?? null, $options['cost_cpu'], $options['cost_memory'], $options['cost_parallel'], $options['length']); + } + + /** + * @param string $password Input password to validate + * @param string $hash Hash to verify password against + * + * @return boolean true if password matches hash + */ + public function verify(string $password, string $hash): bool { + return $hash === $this->hash($password); + } + + /** + * Get default options for specific hashing algo + * + * @return mixed options named array + */ + public function getDefaultOptions(): mixed { + return [ 'cost_cpu' => 8, 'cost_memory' => 14, 'cost_parallel' => 1, 'length' => 64 ]; + } +} \ No newline at end of file diff --git a/tests/unit/Auth/AuthTest.php b/tests/unit/Auth/AuthTest.php index 584a04a416..3ebeb89349 100644 --- a/tests/unit/Auth/AuthTest.php +++ b/tests/unit/Auth/AuthTest.php @@ -48,25 +48,85 @@ class AuthTest extends TestCase public function testPassword() { - $secret = 'secret'; + /* + General tests, using pre-defined hashes generated by online tools + */ - // bcrypt - $static = '$2y$08$PDbMtV18J1KOBI9tIYabBuyUwBrtXPGhLxCy9pWP6xkldVOKLrLKy'; - $dynamic = Auth::passwordHash($secret, 'bcrypt'); - $this->assertEquals(true, Auth::passwordVerify($secret, $static, 'bcrypt')); - $this->assertEquals(true, Auth::passwordVerify($secret, $dynamic, 'bcrypt')); + // Bcrypt - Version Y + $plain = 'secret'; + $hash = '$2y$08$PDbMtV18J1KOBI9tIYabBuyUwBrtXPGhLxCy9pWP6xkldVOKLrLKy'; + $generatedHash = Auth::passwordHash($plain, 'bcrypt'); + $this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'bcrypt')); + $this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'bcrypt')); - // md5 - $static = '5ebe2294ecd0e0f08eab7690d2a6ee69'; - $dynamic = Auth::passwordHash($secret, 'md5'); - $this->assertEquals(true, Auth::passwordVerify($secret, $static, 'md5')); - $this->assertEquals(true, Auth::passwordVerify($secret, $dynamic, 'md5')); + // Bcrypt - Version A + $plain = 'test123'; + $hash = '$2a$12$3f2ZaARQ1AmhtQWx2nmQpuXcWfTj1YV2/Hl54e8uKxIzJe3IfwLiu'; + $generatedHash = Auth::passwordHash($plain, 'bcrypt'); + $this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'bcrypt')); + $this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'bcrypt')); - // scrypt - $static = 'baaf807babd0a518e50edef84ce792e8b0520b3f1c3563504db3562778145c1fd2955f4f5aff398b8d3501f8bfcbdefc8cb51fa58d64cc3c41a6dc3319d83222'; - $dynamic = Auth::passwordHash($secret, 'scrypt'); - $this->assertEquals(true, Auth::passwordVerify($secret, $static, 'scrypt')); - $this->assertEquals(true, Auth::passwordVerify($secret, $dynamic, 'scrypt')); + // Bcrypt - Cost 5 + $plain = 'hello-world'; + $hash = '$2a$05$IjrtSz6SN7UJ6Sh3l.b5jODEvEG2LMJTPAHIaLWRvlWx7if3VMkFO'; + $generatedHash = Auth::passwordHash($plain, 'bcrypt'); + $this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'bcrypt')); + $this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'bcrypt')); + + // Bcrypt - Cost 15 + $plain = 'super-secret-password'; + $hash = '$2a$15$DS0ZzbsFZYumH/E4Qj5oeOHnBcM3nCCsCA2m4Goigat/0iMVQC4Na'; + $generatedHash = Auth::passwordHash($plain, 'bcrypt'); + $this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'bcrypt')); + $this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'bcrypt')); + + // MD5 - Short + $plain = 'appwrite'; + $hash = '144fa7eaa4904e8ee120651997f70dcc'; + $generatedHash = Auth::passwordHash($plain, 'md5'); + $this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'md5')); + $this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'md5')); + + // MD5 - Long + $plain = 'AppwriteIsAwesomeBackendAsAServiceThatIsAlsoOpenSourced'; + $hash = '8410e96cf7ac64e0b84c3f8517a82616'; + $generatedHash = Auth::passwordHash($plain, 'md5'); + $this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'md5')); + $this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'md5')); + + // PHPass + $plain = 'pass123'; + $hash = '$P$BVKPmJBZuLch27D4oiMRTEykGLQ9tX0'; + $generatedHash = Auth::passwordHash($plain, 'phpass'); + $this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'phpass')); + $this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'phpass')); + + /* + Provider-specific tests, ensuring functionality of specific use-cases + */ + + // Provider #1 (Database) + $plain = 'example-password'; + $hash = '$2a$10$3bIGRWUes86CICsuchGLj.e.BqdCdg2/1Ud9LvBhJr0j7Dze8PBdS'; + $generatedHash = Auth::passwordHash($plain, 'bcrypt'); + $this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'bcrypt')); + $this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'bcrypt')); + + // Provider #2 (Blog) + $plain = 'your-password'; + $hash = '$P$BkiNDJTpAWXtpaMhEUhUdrv7M0I1g6.'; + $generatedHash = Auth::passwordHash($plain, 'phpass'); + $this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'phpass')); + $this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'phpass')); + + // Provider #2 (Google) + $plain = 'users-password'; + $hash = 'EPKgfALpS9Tvgr/y1ki7ubY4AEGJeWL3teakrnmOacN4XGiyD00lkzEHgqCQ71wGxoi/zb7Y9a4orOtvMV3/Jw=='; + $options = [ 'salt' => '56dFqW+kswqktw==', 'cost_cpu' => 8, 'cost_memory' => 14, 'cost_parallel' => 1, 'length' => 64 ]; + $generatedHash = Auth::passwordHash($plain, 'scrypt', $options); + \var_dump($generatedHash); + $this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'scrypt', $options)); + $this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'scrypt', $options)); } public function testPasswordGenerator()