From 71ee810891694b8d017d9a851e7335490f7ae96b Mon Sep 17 00:00:00 2001 From: David Goodwin Date: Sat, 8 May 2021 21:47:45 +0100 Subject: [PATCH] try and implement more hash mechanisms - see e.g. https://github.com/postfixadmin/postfixadmin/issues/479 and https://github.com/postfixadmin/postfixadmin/issues/379 --- model/PFACrypt.php | 346 ++++++++++++++++++++++++++++++++++++++++++ tests/PacryptTest.php | 84 ++++++++-- 2 files changed, 416 insertions(+), 14 deletions(-) create mode 100644 model/PFACrypt.php diff --git a/model/PFACrypt.php b/model/PFACrypt.php new file mode 100644 index 00000000..8028b4bd --- /dev/null +++ b/model/PFACrypt.php @@ -0,0 +1,346 @@ +algorithm = $algorithm; + } + + public function hash(string $pw, $pw_db) + { + $algorithm = $this->algorithm; + + // try and 'upgrade' some dovecot commands to use local algorithms (rather tnan a dependency on the dovecot binary). + if (preg_match('/^dovecot:/', $algorithm)) { + $tmp = preg_replace('/^dovecot:/', '', $algorithm); + + $supported = [ + 'SHA1', 'SHA1,HEX', 'SHA1.B64', + 'BLF-CRYPT', 'BLF-CRYPT.B64', + 'SHA512-CRYPT', 'SHA512-CRYPT.B64', + 'ARGON2I', + 'ARGON2I.B64', + 'ARGON2ID', + 'ARGON2ID.B64', + 'SHA256', 'SHA256-CRYPT', 'SHA256-CRYPT.B64', + 'SHA512', 'SHA512.B64', + 'MD5', + 'PLAIN-MD5', + 'CRYPT', + ]; + + if (in_array($tmp, $supported)) { + $algorithm = $tmp; + } else { + error_log("Warning: using alogrithm that requires proc_open: $algorithm, consider using one of : " . implode(', ', $supported)); + } + } + + switch ($algorithm) { + + case 'SHA1': + case 'SHA1.B64': + case 'SHA1.HEX': + return $this->hashSha1($pw, $pw_db, $algorithm); + + case 'BLF-CRYPT': + case 'BLF-CRYPT.B64': + return $this->blowfishCrypt($pw, $pw_db, $algorithm); + + case 'SHA512-CRYPT': + case 'SHA512-CRYPT.B64': + return $this->sha512Crypt($pw, $pw_db, $algorithm); + + case 'ARGON2I': + case 'ARGON2I.B64': + return $this->argon2iCrypt($pw, $pw_db, $algorithm); + + case 'ARGON2ID': + case 'ARGON2ID.B64': + return $this->argon2idCrypt($pw, $pw_db, $algorithm); + + case 'SHA256': + return '{SHA256}' . base64_encode(hash('sha256', $pw, true)); + + case 'SHA256-CRYPT': + case 'SHA256-CRYPT.B64': + return $this->sha256Crypt($pw, $pw_db, $algorithm); + + case 'SHA512': + case 'sha512': + case 'sha512b.b64': + case 'SHA512.B64': + return $this->hashSha512($pw, $algorithm); + + case 'md5': + case 'PLAIN-MD5': + return $this->hashMd5($pw, $algorithm); + + case 'MD5': + case 'MD5-CRYPT': + return $this->cryptMd5($pw, $pw_db, $algorithm); + + + case 'CRYPT': + if (!empty($pw_db)) { + $pw_db = preg_replace('/^{CRYPT}/', '', $pw_db); + } + if (empty($pw_db)) { + $pw_db = '$2y$10$' . substr(sha1(random_bytes(8)), 0, 22); + } + return '{CRYPT}' . crypt($pw, $pw_db); + + case 'system': + return _pacrypt_crypt($pw, $pw_db); + case 'cleartext': + return $pw; + case 'CLEAR': + case 'PLAIN': + case 'CLEARTEXT': + if (!empty($pw_db)) { + $pw_db = preg_replace('/^{.*}}/', '', $pw_db); + if (password_verify($pw, $pw_db)) { + return '{' . $algorithm . '}' . $pw; + } + } + return '{' . $algorithm . '}' . $pw; + case 'mysql_encrypt': + return _pacrypt_mysql_encrypt($pw, $pw_db); + + // these are all supported by the above (SHA, + case 'authlib': + return _pacrypt_authlib($pw, $pw_db); + + case 'sha512crypt.b64': + return $this->pacrypt_sha512crypt_b64($pw, $pw_db); + + } + + if (preg_match("/^dovecot:/", $algorithm)) { + return _pacrypt_dovecot($pw, $pw_db); + } + + if (substr($algorithm, 0, 9) === 'php_crypt') { + return _pacrypt_php_crypt($pw, $pw_db); + } + + throw new Exception('unknown/invalid $CONF["encrypt"] setting: ' . $algorithm); + } + + + public function hashSha1(string $pw, string $pw_db = '', string $algorithm = 'SHA1'): string + { + $hash = hash('sha1', $pw, true); + + if (preg_match('/\.HEX$/', $algorithm)) { + $hash = bin2hex($hash); + } else { + $hash = base64_encode($hash); + } + return "{{$algorithm}}{$hash}"; + } + + public function hashSha512(string $pw, string $algorithm = 'SHA512') + { + $prefix = '{SHA512}'; + + if ($algorithm == 'SHA512.B64' || $algorithm == 'sha512b.b64') { + $prefix = '{SHA512.B64}'; + } + + return $prefix . base64_encode(hash('sha512', $pw, true)); + } + + public function hashMd5(string $pw, string $algorithm = 'PLAIN-MD5'): string + { + if ($algorithm == 'PLAIN-MD5') { + return '{PLAIN-MD5}' . md5($pw); + } + + return md5($pw); + } + + public function cryptMd5(string $pw, string $pw_db = '', $algorithm = 'MD5-CRYPT') + { + if (!empty($pw_db)) { + $pw_db = preg_replace('/^{MD5.*}/', '', $pw_db); + } + if (empty($pw_db)) { + $pw_db = '$1$' . substr(sha1(random_bytes(8)), 0, 16); + } + return "{{$algorithm}}" . crypt($pw, $pw_db); + } + + public function blowfishCrypt(string $pw, string $pw_db = '', string $algorithm = 'BLF-CRYPT'): string + { + if (!empty($pw_db)) { + if ($algorithm == 'BLF-CRYPT') { + $pw_db = preg_replace('/^{BLF-CRYPT}/', '', $pw_db); + } + if ($algorithm == 'BLF-CRYPT.B64') { + $pw_db = base64_decode(preg_replace('/^{BLF-CRYPT.B64}/', '', $pw_db)); + } + $hash = crypt($pw, $pw_db); + + if ($algorithm == 'BLF-CRYPT.B64') { + $hash = base64_encode($hash); + } + return "{{$algorithm}}{$hash}"; + } + + $r = password_hash($pw, PASSWORD_BCRYPT); + if (!is_string($r)) { + throw new \RuntimeException("Failed to generate password"); + } + if ($algorithm == 'BLF-CRYPT.B64') { + return '{BLF-CRYPT.B64}' . base64_encode($r); + } + return '{BLF-CRYPT}' . $r; + } + + public function sha256Crypt(string $pw, string $pw_db = '', string $algorithm = 'SHA256-CRYPT'): string + { + if (!empty($pw_db)) { + $pw_db = preg_replace('/^{SHA256-CRYPT(\.B64)?}/', '', $pw_db); + + if ($algorithm == 'SHA256-CRYPT.B64') { + $pw_db = base64_decode($pw_db); + } + } + + if (empty($pw_db)) { + $pw_db = '$5$' . substr(sha1(random_bytes(8)), 0, 16); + } + + $hash = crypt($pw, $pw_db); + + if ($algorithm == 'SHA256-CRYPT.B64') { + return '{SHA256-CRYPT.B64}' . base64_encode($hash); + } + return "{SHA256-CRYPT}" . $hash; + } + + public function sha512Crypt(string $pw, string $pw_db = '', $algorithm = 'SHA512-CRYPT'): string + { + if (!empty($pw_db)) { + $pw_db = preg_replace('/^{SHA512-CRYPT(\.B64)?}/', '', $pw_db); + + if ($algorithm == 'SHA512-CRYPT.B64') { + $pw_db = base64_decode($pw_db); + } + } + + if (empty($pw_db)) { + $pw_db = '$6$' . substr(sha1(random_bytes(8)), 0, 16); + } + + $hash = crypt($pw, $pw_db); + + if ($algorithm == 'SHA512-CRYPT.B64') { + $hash = base64_encode($hash); + return "{SHA512-CRYPT.B64}{$hash}"; + } + + return "{SHA512-CRYPT}$hash"; + } + + public function argon2ICrypt(string $pw, string $pw_db = '', $algorithm = 'ARGON2I'): string + { + if (!empty($pw_db)) { + $pw_db = preg_replace('/^{ARGON2I(\.B64)?}/', '', $pw_db); + $orig_pwdb = $pw_db; + if ($algorithm == 'ARGON2I.B64') { + $pw_db = base64_decode($pw_db); + } + + if (password_verify($pw, $pw_db)) { + return "{{$algorithm}}" . $orig_pwdb; + } + $hash = password_hash($pw, PASSWORD_ARGON2I); + if ($algorithm == 'ARGON2I') { + return '{ARGON2I}' . $hash; + } + return '{ARGON2I.B64}' . base64_encode($hash); + ; + } + + $hash = password_hash($pw, PASSWORD_ARGON2I); + + if ($algorithm == 'ARGON2I') { + return '{ARGON2I}' . $hash; + } + + return "{ARGON2I.B64}" . base64_encode($hash); + } + + public function argon2idCrypt(string $pw, string $pw_db = '', string $algorithm = 'ARGON2ID'): string + { + if (!defined('PASSWORD_ARGON2ID')) { + throw new \Exception("Requires PHP 7.3+"); + } + + if (!empty($pw_db)) { + $pw_db = preg_replace('/^{ARGON2ID(\.B64)?}/', '', $pw_db); + + $orig_pwdb = $pw_db; + + if ($algorithm == 'ARGON2ID.B64') { + $pw_db = base64_decode($pw_db); + } + + if (password_verify($pw, $pw_db)) { + return "{{$algorithm}}" . $orig_pwdb; + } + + $hash = password_hash($pw, PASSWORD_ARGON2ID); + + if ($algorithm == 'ARGON2ID') { + return '{ARGON2ID}' . $hash; + } + // if($algorithm == 'ARGON2ID.B64') { + return '{ARGON2ID.B64}' . base64_encode($hash); + } + + $hash = password_hash($pw, PASSWORD_ARGON2ID); + + if ($algorithm == 'ARGON2ID') { + return '{ARGON2ID}' . $hash; + } + return '{ARGON2ID.B64}' . base64_encode($hash); + } + + + /** + * @see https://github.com/postfixadmin/postfixadmin/issues/58 + * + * Note, this is really a base64 encoded CRYPT formatted hash; this isn't the same as a + * sha512 hash that's been base64 encoded. + */ + public function pacrypt_sha512crypt_b64($pw, $pw_db = "") + { + if (!function_exists('random_bytes') || !function_exists('crypt') || !defined('CRYPT_SHA512') || !function_exists('mb_substr')) { + throw new Exception("sha512.b64 not supported!"); + } + if (!$pw_db) { + $salt = mb_substr(rtrim(base64_encode(random_bytes(16)), '='), 0, 16, '8bit'); + return '{SHA512-CRYPT.B64}' . base64_encode(crypt($pw, '$6$' . $salt)); + } + + $password = "#Thepasswordcannotbeverified"; + if (strncmp($pw_db, '{SHA512-CRYPT.B64}', 18) == 0) { + $dcpwd = base64_decode(mb_substr($pw_db, 18, null, '8bit'), true); + if ($dcpwd !== false && !empty($dcpwd) && strncmp($dcpwd, '$6$', 3) == 0) { + $password = '{SHA512-CRYPT.B64}' . base64_encode(crypt($pw, $dcpwd)); + } + } elseif (strncmp($pw_db, '{MD5-CRYPT}', 11) == 0) { + $dcpwd = mb_substr($pw_db, 11, null, '8bit'); + if (!empty($dcpwd) && strncmp($dcpwd, '$1$', 3) == 0) { + $password = '{MD5-CRYPT}' . crypt($pw, $dcpwd); + } + } + return $password; + } +} diff --git a/tests/PacryptTest.php b/tests/PacryptTest.php index e751f217..5281cad8 100644 --- a/tests/PacryptTest.php +++ b/tests/PacryptTest.php @@ -39,7 +39,7 @@ class PaCryptTest extends \PHPUnit\Framework\TestCase $this->assertNotEquals('test', $hash); $this->assertNotEquals('test', $hash2); - $this->assertTrue( hash_equals($hash, _pacrypt_mysql_encrypt('test1', $hash) ), "hashes should equal...."); + $this->assertTrue(hash_equals($hash, _pacrypt_mysql_encrypt('test1', $hash)), "hashes should equal...."); } public function testAuthlib() @@ -68,6 +68,7 @@ class PaCryptTest extends \PHPUnit\Framework\TestCase public function testPacryptDovecot() { global $CONF; + if (!file_exists('/usr/bin/doveadm')) { $this->markTestSkipped("No /usr/bin/doveadm"); } @@ -77,6 +78,7 @@ class PaCryptTest extends \PHPUnit\Framework\TestCase $expected_hash = '{SHA1}qUqP5cyxm6YcTAhz05Hph5gvu9M='; + $this->assertEquals($expected_hash, _pacrypt_dovecot('test', '')); $this->assertEquals($expected_hash, _pacrypt_dovecot('test', $expected_hash)); @@ -155,28 +157,82 @@ class PaCryptTest extends \PHPUnit\Framework\TestCase $this->assertFalse(strcmp($str1, $str2) == 0 && strcmp($str1, $str3) == 0); } - public function testSha512B64() + + public function testNewDovecotStuff() { - $str1 = _pacrypt_sha512_b64('test', ''); - $str2 = _pacrypt_sha512_b64('test', ''); + global $CONF; - $this->assertNotEmpty($str1); - $this->assertNotEmpty($str2); - $this->assertNotEquals($str1, $str2); // should have different salts + $algo_list = [ + 'SHA1', + 'SHA1.B64', + 'BLF-CRYPT', + 'BLF-CRYPT.B64', - $actualHash = '{SHA512-CRYPT.B64}JDYkM2NWcFM1WFNlUHl5MzdwSiRZWW80d0FmeWg5MXpxcS4uY3dtYUR1Y1RodTJGTDY1NHpXNUNvRU0wT3hXVFFzZkxIZ1JJSTZmT281OVpDUWJOTTF2L0JXajloME0vVjJNbENNMUdwLg=='; + 'SHA512-CRYPT', + 'SHA512-CRYPT.B64', - $check = _pacrypt_sha512_b64('test', $actualHash); + 'SHA512', + 'SHA512.B64', - $this->assertTrue(hash_equals($check, $actualHash)); + // 'DES-CRYPT', + 'CRYPT', + 'MD5-CRYPT', + 'PLAIN-MD5', + 'PLAIN', + 'CLEAR', + 'CLEARTEXT', + 'ARGON2I', + "ARGON2ID", + 'MD5', + 'SHA256', + 'SHA256-CRYPT', + 'SHA256-CRYPT.B64', + ]; - $str3 = _pacrypt_sha512_b64('foo', ''); + // should all be from 'test123', generated via dovecot. + $example_json = <<<'EOF' +{ + "SHA1": "{SHA1}cojt0Pw\/\/L6ToM8G41aOKFIWh7w=", + "SHA1.B64": "{SHA1.B64}cojt0Pw\/\/L6ToM8G41aOKFIWh7w=", + "BLF-CRYPT": "{BLF-CRYPT}$2y$05$cEEZv2h\/NtLXII.emi2TP.rMZyB7VRSkyToXWBqqz6cXDoyay166q", + "BLF-CRYPT.B64": "{BLF-CRYPT.B64}JDJ5JDA1JEhlR0lBeGFHR2tNUGxjRWpyeFc0eU9oRjZZZ1NuTWVOTXFxNWp4bmFwVjUwdGU3c2x2L1VT", + "SHA512-CRYPT": "{SHA512-CRYPT}$6$MViNQUSbWyXWL9wZ$63VsBU2a\/ZFb9f\/dK4EmaXABE9jAcNltR7y6a2tXLKoV5F5jMezno.2KpmtD3U0FDjfa7A.pkCluVMlZJ.F64.", + "SHA512-CRYPT.B64": "{SHA512-CRYPT.B64}JDYkR2JwY3NiZXNMWk9DdERXbiRYdXlhdEZTdy9oa3lyUFE0d24wenpGQTZrSlpTUE9QVWdPcjVRUC40bTRMTjEzdy81aWMvWTdDZllRMWVqSWlhNkd3Q2Z0ZnNjZEFpam9OWjl3OU5tLw==", + "SHA512": "{SHA512}2u9JU7l4M2XK1mFSI3IFBsxGxRZ80Wq1APpZeqCP+WTrJPsZaH8012Zfd4\/LbFNY\/ApbgeFmLPkPc6JnHFP5kQ==", + "SHA512.B64": "{SHA512.B64}2u9JU7l4M2XK1mFSI3IFBsxGxRZ80Wq1APpZeqCP+WTrJPsZaH8012Zfd4\/LbFNY\/ApbgeFmLPkPc6JnHFP5kQ==", + "CRYPT": "{CRYPT}$2y$05$ORqzr0AagWr25v3ixHD5QuMXympIoNTbipEFZz6aAmovGNoij2vDO", + "MD5-CRYPT": "{MD5-CRYPT}$1$AIjpWveQ$2s3eEAbZiqkJhMYUIVR240", + "PLAIN-MD5": "{PLAIN-MD5}cc03e747a6afbbcbf8be7668acfebee5", + "PLAIN": "{PLAIN}test123", + "CLEAR": "{CLEAR}test123", + "CLEARTEXT": "{CLEARTEXT}test123", + "ARGON2I": "{ARGON2I}$argon2i$v=19$m=32768,t=4,p=1$xoOcAGa27k0Sr6ZPbA9ODw$wl\/KAZVmJooD\/35IFG5oGwyQiAREXrLss5BPS1PDKfA", + "ARGON2ID": "{ARGON2ID}$argon2id$v=19$m=65536,t=3,p=1$eaXP376O9/VxleLw9OQIxg$jOoDyECeRRV4eta3eSN/j0RdBgqaA1VBGAA/pbviI20", + "ARGON2ID.B64" : "{ARGON2ID.B64}JGFyZ29uMmlkJHY9MTkkbT02NTUzNix0PTMscD0xJEljdG9DWko1T04zWlYzM3I0TVMrNEEkMUVtNTJRWkdsRlJzNnBsRXpwVmtMeVd4dVNPRUZ2dUZnaVNhTmNlb08rOA==", + "MD5": "{MD5}$1$q7P.FNm9$\/W8EtauGkrxlhQep80T8..", + "SHA256": "{SHA256}7NcYcNGWMxapfjrDQIyYNa2M8PPBvHA1J8MCZVNPda4=", + "SHA256-CRYPT": "{SHA256-CRYPT}$5$CFly6wzfn2az3U8j$EhfQPTdjpMGAisfCjCKektLke5GGEmtdLVaCZSmsKw2", + "SHA256-CRYPT.B64": "{SHA256-CRYPT.B64}JDUkUTZZS1ZzZS5sSVJoLndodCR6TWNOUVFVVkhtTmM1ME1SQk9TR3BEeGpRY2M1TzJTQ1lkbWhPN1YxeHlD" +} +EOF; - $this->assertNotEmpty($str3); + $algo_example = json_decode($example_json, true); - $this->assertFalse(hash_equals('test', $str3)); + foreach ($algo_example as $algorithm => $example_hash) { + $CONF['encrypt'] = $algorithm; + $pfa_new_hash = pacrypt('test123'); - $this->assertTrue(hash_equals(_pacrypt_sha512_b64('foo', $str3), $str3)); + $pacrypt_check = pacrypt('test123', $example_hash); + $pacrypt_sanity = pacrypt('zzzzzzz', $example_hash); + + $this->assertNotEquals($pacrypt_sanity, $example_hash, "Should not match, zzzz password. $algorithm / $pacrypt_sanity"); + + $this->assertEquals($pacrypt_check, $example_hash, "Should match, algorithm: $algorithm generated:{$pacrypt_check} vs example:{$example_hash}"); + + $new_new = pacrypt('test123', $pfa_new_hash); + + $this->assertEquals($pfa_new_hash, $new_new, "Trying: $algorithm => gave: $new_new with $pfa_new_hash ... "); + } } }