diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 2318cd5d..d0c1f54f 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -3,7 +3,38 @@ name: GitHubBuild on: [push] jobs: - build: + lint_etc: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 7.4 + tools: composer + + - name: run install.sh + run: /bin/bash install.sh + + - name: check composer + run: composer validate + +# Needing to 'update' here isn't ideal, but we need to cope with tests that run under different PHP versions :-/ + - name: Install dependencies + run: composer update --prefer-dist -n + + - name: check formatting + run: composer check-format + + - name: touch config.local.php + run: touch config.local.php + + - name: psalm static analysis + run: composer psalm + + testsuite: + needs: [lint_etc] runs-on: ubuntu-latest strategy: matrix: @@ -18,11 +49,8 @@ jobs: php-version: ${{ matrix.php-versions }} tools: composer - - name: Validate composer.json and composer.lock - run: composer validate - - - name: setup templates_c - run: mkdir templates_c || true + - name: run insall.sh + run: /bin/bash install.sh - name: touch config.local.php run: touch config.local.php && php -v @@ -31,18 +59,10 @@ jobs: run: composer install --prefer-dist -n - name: Build/test - run: composer build + run: composer test - - name: build coveralls coverage - run: php -m xdebug.mode=coverage vendor/bin/phpunit tests - - - name: Coveralls - run: vendor/bin/php-coveralls ./clover.xml || true - env: - COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} - - post_build: - needs: [build] + build_coverage_report: + needs: [testsuite] runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 @@ -53,14 +73,14 @@ jobs: php-version: '7.4' tools: composer - - name: setup templates_c - run: mkdir templates_c || true + - name: run insall.sh + run: /bin/bash install.sh - name: touch config.local.php run: touch config.local.php && php -v - name: Install dependencies - run: composer install --prefer-dist -n + run: composer update --prefer-dist -n - name: build coveralls coverage run: php -d xdebug.mode=coverage vendor/bin/phpunit tests diff --git a/.gitignore b/.gitignore index 4833c98d..c6d4e8ea 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ /config.local.php /templates_c/*.tpl.php /templates_c/*menu.conf.php -/vendor/ /.php_cs.cache /.idea +/vendor /composer.lock diff --git a/DOCUMENTS/HASHING.md b/DOCUMENTS/HASHING.md index c3c075cc..aae999fd 100644 --- a/DOCUMENTS/HASHING.md +++ b/DOCUMENTS/HASHING.md @@ -6,6 +6,9 @@ They should not be stored in plain text. Whatever format you choose will need to be supported by your IMAP server (and whatever provides SASL auth for Postfix) +If you can, use a format that includes a different salt per password (e.g. one of the crypt variants, like Blowfish (BLF-CRYPT) or Argon2I/Argon2ID). +Try and avoid formats that are unsalted hashes (md5, SHA1) as these offer minimal protection in the event of a data leak. + ## Configuration See config.inc.php (or config.local.php) and look for @@ -20,6 +23,49 @@ This document is probably not complete. It possibly provides better documentation than was present before. This may not say much. +Supported hash formats include : + + * MD5-CRYPT (aka MD5), + * SHA1, + * SHA1-CRYPT, + * SSHA (4 char salted sha1), + * BLF-CRYPT (Blowfish), + * SHA512, + * SHA512-CRYPT, + * ARGON2I, + * ARGON2ID, + * SHA256, + * SHA256-CRYPT, + * PLAIN-MD5 (aka md5) + * CRYPT + +Historically PostfixAdmin has supported all dovecot algorithms (methods) by using the 'doveadm' system binary. As of XXXXXXX, we attempted to use a native/PHP implementation for a number of these to remove issues caused by use of proc_open / dovecot file permissions etc (see e.g. #379). + +It's recommended you use the algorithm/mechanism from your MTA, and configure PostfixAdmin with the same value prefixed by the MTA name - + +For example, if dovecot has `default_pass_scheme = SHA256` use `$CONF['encrypt'] = 'dovecot:SHA256'; ` in PostfixAdmin. + + +| Dovecot pass scheme | PostfixAdmin `$CONF['encrypt']` setting | +|---------------------|-----------------------------------------| +| SHA256 | dovecot:SHA256 | +| SHA256-CRYPT.B64 | dovecot:SHA256-CRYPT.B64 | +| SHA256-CRYPT | dovecot:SHA256-CRYPT | +| SHA512-CRYPT | dovecot:SHA512-CRYPT | +| ARGON2I | dovecot:ARGON2I | +| ARGON2ID | dovecot:ARGON2ID | + + + +| Courier Example | PostfixAdmin | +|-----------------|--------------| +| md5 | courier:md5 | +| md5raw | courier:md5raw | +| sha1 | courier:sha1 | +| ssha | courier:ssha | +| sha256 | courier:sha256 | + + ### cleartext No hashing. May be useful for debugging. @@ -58,9 +104,9 @@ You should not use this (it does not offer a high level of security), but is pro Uses PHP's crypt function. -Probably throws an E_NOTICE. Avoid? +Probably throws an E_NOTICE. -example : `$1$tWgqTIuF$1HFciCXrhVpACGjBMxNr/0` +Example : `$1$tWgqTIuF$1HFciCXrhVpACGjBMxNr/0` ### authlib @@ -86,16 +132,18 @@ Presumably weak. Uses sha1, base64 encoded. Unsalted. Avoid. -### dovecot:CRYPT-METHOD +### dovecot:METHOD -Uses dovecot binary to produce hash. +May use dovecot binary to produce hash, if the format you request isn't in PFACrypt::DOVECOT_NATIVE -Pros - +Using a format that PostfixAdmin doesn't support natively has the following pros/cons : + +#### Pros * Minimal dependency on PostfixAdmin / PHP code. * Hash should definitely work with dovecot! -Cons - +#### Cons * file permissions and/or execution of doveadm by the web server may be problematic. * requires: proc_open(...) - which might be blocked by e.g. safemode. diff --git a/INSTALL.TXT b/INSTALL.TXT index 8e93cb52..e0d60b73 100644 --- a/INSTALL.TXT +++ b/INSTALL.TXT @@ -41,10 +41,10 @@ DOCUMENTS/ folder. (if you installed PostfixAdmin as RPM or DEB package, you can obviously skip this step.) -Assuming we are installing Postfixadmin into /srv/postfixadmin, then something like this should work. Please check https://github.com/postfixadmin/postfixadmin/releases to get the latest stable release first (the 3.2.4 version/url below is probably stale) +Assuming we are installing Postfixadmin into /srv/postfixadmin, then something like this should work. Please check https://github.com/postfixadmin/postfixadmin/releases to get the latest stable release first (the 3.2.10 version/url below is probably stale) $ cd /srv/ - $ wget -O postfixadmin.tgz https://github.com/postfixadmin/postfixadmin/archive/postfixadmin-3.2.4.tar.gz + $ wget -O postfixadmin.tgz https://github.com/postfixadmin/postfixadmin/archive/postfixadmin-3.2.10.tar.gz $ tar -zxvf postfixadmin.tgz $ mv postfixadmin-postfixadmin-3.2 postfixadmin @@ -53,7 +53,20 @@ Alternatively : $ cd /srv $ git clone https://github.com/postfixadmin/postfixadmin.git $ cd postfixadmin - $ git checkout postfixadmin-3.2.4 + $ git checkout postfixadmin-3.2.10 + +If you're happy to try out newer functionality and perhaps hit unfixed bugs, you can try the 'master' branch by a `git checkout master` (or don't run the final git checkout in the above list). + +If you're using the 'master' branch, you'll need to also run : + +```bash +/bin/bash install.sh +``` + +Which will + * install the 'composer' tool locally (composer.phar) and + * download dependent PHP libraries. + * create a templates_c directory if one does not exist. 2. Setup Web Server ------------------- diff --git a/common.php b/common.php index 2e79ceec..4677d6f3 100644 --- a/common.php +++ b/common.php @@ -1,4 +1,7 @@ =7.0" + "php": ">=7.2", + "postfixadmin/password-hashing": "^0.0.1" }, "require-dev": { "ext-mysqli": "*", @@ -33,6 +37,7 @@ "shardj/zf1-future" : "^1.12" }, "autoload": { + "classmap" : [ "model/" ], "files": [ "config.inc.php", "functions.inc.php", diff --git a/config.inc.php b/config.inc.php index 03758941..32bc68e2 100644 --- a/config.inc.php +++ b/config.inc.php @@ -187,7 +187,7 @@ $CONF['smtp_sendmail_tls'] = 'NO'; // authlib = support for courier-authlib style passwords - also set $CONF['authlib_default_flavor'] // dovecot:CRYPT-METHOD = use dovecotpw -s 'CRYPT-METHOD'. Example: dovecot:CRAM-MD5 // php_crypt:CRYPT-METHOD:DIFFICULTY:PREFIX = use PHP built in crypt()-function. Example: php_crypt:SHA512:50000 -// - php_crypt CRYPT-METHOD: Supported values are DES, MD5, BLOWFISH, SHA256, SHA512 +// - php_crypt CRYPT-METHOD: Supported values are DES, MD5, BLOWFISH, SHA256, SHA512 (default) // - php_crypt DIFFICULTY: Larger value is more secure, but uses more CPU and time for each login. // - php_crypt DIFFICULTY: Set this according to your CPU processing power. // - php_crypt DIFFICULTY: Supported values are BLOWFISH:4-31, SHA256:1000-999999999, SHA512:1000-999999999 @@ -197,7 +197,7 @@ $CONF['smtp_sendmail_tls'] = 'NO'; // - you'll need at least dovecot 2.1 for salted passwords ('doveadm pw' 2.0.x doesn't support the '-t' option) // - dovecot 2.0.0 - 2.0.7 is not supported // - php_crypt PREFIX: hash has specified prefix - example: php_crypt:SHA512::{SHA256-CRYPT} -// sha512.b64 - {SHA512-CRYPT.B64} (base64 encoded sha512) (no dovecot dependency; should support migration from md5crypt) +// sha512.b64 - {SHA512-CRYPT.B64} (base64 encoded sha512 crypt) (no dovecot dependency; should support migration from md5crypt) $CONF['encrypt'] = 'php_crypt'; // In what flavor should courier-authlib style passwords be encrypted? diff --git a/functions.inc.php b/functions.inc.php index 839a8db4..fec83697 100644 --- a/functions.inc.php +++ b/functions.inc.php @@ -936,6 +936,7 @@ function validate_password($password) * @param string $pw * @param string $pw_db - encrypted hash * @return string crypt'ed password, should equal $pw_db if $pw matches the original + * @deprecated */ function _pacrypt_md5crypt($pw, $pw_db = '') { @@ -952,6 +953,7 @@ function _pacrypt_md5crypt($pw, $pw_db = '') /** * @todo fix this to not throw an E_NOTICE or deprecate/remove. + * @deprecated */ function _pacrypt_crypt($pw, $pw_db = '') { @@ -1291,65 +1293,44 @@ function _php_crypt_random_string($characters, $length) * @param string $pw_db optional encrypted password * @return string encrypted password - if this matches $pw_db then the original password is $pw. */ -function pacrypt($pw, $pw_db="") +function pacrypt($pw, $pw_db = "") { global $CONF; - switch ($CONF['encrypt']) { - case 'md5crypt': - return _pacrypt_md5crypt($pw, $pw_db); - case 'md5': - return md5($pw); - case 'system': - return _pacrypt_crypt($pw, $pw_db); - case 'cleartext': - return $pw; - case 'mysql_encrypt': - return _pacrypt_mysql_encrypt($pw, $pw_db); - case 'authlib': - return _pacrypt_authlib($pw, $pw_db); - case 'sha512.b64': - return _pacrypt_sha512_b64($pw, $pw_db); + $mechanism = $CONF['encrypt'] ?? 'CRYPT'; + + $mechanism = strtoupper($mechanism); + + $crypts = ['PHP_CRYPT', 'MD5CRYPT', 'PHP_CRYPT:DES', 'PHP_CRYPT:MD5', 'PHP_CRYPT:SHA256']; + + if (in_array($mechanism, $crypts)) { + $mechanism = 'CRYPT'; + } + if ($mechanism == 'PHP_CRYPT:SHA512') { + $mechanism = 'SHA512-CRYPT'; } - if (preg_match("/^dovecot:/", $CONF['encrypt'])) { - return _pacrypt_dovecot($pw, $pw_db); + if ($mechanism == 'SHA512.B64') { + // postfixadmin incorrectly uses this as a SHA512-CRYPT.B64 + $mechanism = 'SHA512-CRYPT.B64'; + } + + if (preg_match('/^DOVECOT:(.*)$/i', $mechanism, $matches)) { + $mechanism = strtoupper($matches[1]); } - if (substr($CONF['encrypt'], 0, 9) === 'php_crypt') { - return _pacrypt_php_crypt($pw, $pw_db); + if (preg_match('/^COURIER:(.*)$/i', $mechanism, $matches)) { + $mechanism = strtoupper($mechanism); + } + if (empty($pw_db)) { + $pw_db = null; } - throw new Exception('unknown/invalid $CONF["encrypt"] setting: ' . $CONF['encrypt']); -} - -/** - * @see https://github.com/postfixadmin/postfixadmin/issues/58 - */ -function _pacrypt_sha512_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 ($mechanism == 'AUTHLIB') { + return _pacrypt_authlib($pw, $pw_db); } - 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; + $hasher = new \PostfixAdmin\PasswordHashing\Crypt($mechanism); + return $hasher->crypt($pw, $pw_db); } /** @@ -1360,6 +1341,7 @@ function _pacrypt_sha512_b64($pw, $pw_db="") * @param string $salt (optional) * @param string $magic (optional) * @return string hashed password in crypt format. + * @deprecated see PFACrypt::cryptMd5() (note this returns {MD5} prefix */ function md5crypt($pw, $salt="", $magic="") { diff --git a/install.sh b/install.sh new file mode 100644 index 00000000..116e2cba --- /dev/null +++ b/install.sh @@ -0,0 +1,62 @@ +#!/bin/bash + +set -eu + +# PostfixAdmin install script. +# 1. Downloads 'composer.phar' to the current directory. +# 2. Runs 'php composer.phar install' which should install required runtime libraries for Postfixadmin +# 3. Runs 'mkdir templates_c && chmod 777 templates_c' + +PATH=/bin:/usr/bin:/usr/local/bin +export PATH + +COMPOSER_URL=https://getcomposer.org/download/latest-stable/composer.phar + +type php >/dev/null 2>&1 || { echo >&2 "I require php but it's not installed. Aborting."; exit 1; } + +cd "$(dirname "$0")" + +# Check for $(pwd)/composer.phar + +echo " * Checking for composer.phar " + +if [ ! -f composer.phar ]; then + + echo " * Trying to download composer.phar from $COMPOSER_URL " + # try and download it one way or another + if [ -x /usr/bin/wget ]; then + wget -q -O composer.phar $COMPOSER_URL + else + if [ -x /usr/bin/curl ]; then + curl -o composer.phar $COMPOSER_URL + else + echo " ** Could not find wget or curl; please download $COMPOSER_URL to pwd" >/dev/stderr + exit 1 + fi + fi +fi + +echo " * Running composer install --no-dev" + +php composer.phar install --prefer-dist -n --no-dev + + +if [ ! -d templates_c ]; then + + + mkdir -p templates_c && chmod 777 templates_c + + echo + echo " Warning: " + echo " templates_c directory didn't exist, now created." + echo + echo " You should change the ownership and reduce permissions on templates_c to 750. " + echo " The ownership needs to match the user used to execute PHP scripts, perhaps 'www-data' or 'httpd'" + echo + echo " e.g. chown www-data templates_c && chmod 750 templates_c" + echo +fi +echo +echo "Please continue configuration / setup within your web browser. " +echo "See also : https://github.com/postfixadmin/postfixadmin/blob/master/INSTALL.TXT#L58 " +echo diff --git a/model/PFACrypt.php b/model/PFACrypt.php new file mode 100644 index 00000000..a43edfc0 --- /dev/null +++ b/model/PFACrypt.php @@ -0,0 +1,359 @@ +algorithm = $algorithm; + } + + /** + * When called with a 'pw' and a 'pw_db' (hash from e.g. database). + * + * If the return value matches $pw_db, then the plain text password ('pw') is correct. + * + * @param string $pw - plain text password + * @param string $pw_db - hash from e.g. database (what we're comparing $pw to). + * @return string if $pw is correct (hashes to $pw_db) then we return $pw_db. Else we return a new hash. + * + * @throws Exception + */ + public function pacrypt(string $pw, string $pw_db = ''): string + { + $algorithm = strtoupper($this->algorithm); + + // try and 'upgrade' some dovecot commands to use local algorithms (rather tnan a dependency on the dovecot binary). + if (preg_match('/^DOVECOT:/i', $algorithm)) { + $tmp = preg_replace('/^DOVECOT:/i', '', $algorithm); + if (in_array($tmp, self::DOVECOT_NATIVE)) { + $algorithm = $tmp; + } else { + error_log("Warning: using algorithm that requires proc_open: $algorithm, consider using one of : " . implode(', ', self::DOVECOT_NATIVE)); + } + } + + if (!empty($pw_db) && preg_match('/^{([0-9a-z-\.]+)}/i', $pw_db, $matches)) { + $method_in_hash = $matches[1]; + if ('COURIER:' . strtoupper($method_in_hash) == $algorithm) { + // don't try and be clever. + } elseif ($algorithm != $method_in_hash) { + error_log("Hey, you fed me a password using {$method_in_hash}, but the system is configured to use {$algorithm}"); + $algorithm = $method_in_hash; + } + } + if ($algorithm == 'SHA512CRYPT.B64') { + $algorithm = 'SHA512-CRYPT.B64'; + } + + + 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 'SSHA': + case 'COURIER:SSHA': + return $this->hashSha1Salted($pw, $pw_db); + + case 'SHA256': + case 'COURIER:SHA256': + return $this->hashSha256($pw); + + case 'SHA256-CRYPT': + case 'SHA256-CRYPT.B64': + return $this->sha256Crypt($pw, $pw_db, $algorithm); + + case 'SHA512': + case 'SHA512B.b64': + case 'SHA512.B64': + return $this->hashSha512($pw, $algorithm); + + case 'PLAIN-MD5': // {PLAIN-MD5} prefix + case 'MD5': // no prefix + return $this->hashMd5($pw, $algorithm); // this is hex encoded. + + case 'COURIER:MD5': + return '{MD5}' . base64_encode(md5($pw, true)); // seems to need to be base64 encoded. + + case 'COURIER:MD5RAW': + return '{MD5RAW}' . md5($pw); + + 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 crypt($pw, $pw_db); + + case 'CLEAR': + case 'PLAIN': + case 'CLEARTEXT': + if (!empty($pw_db)) { + if ($pw_db == "{{$algorithm}}$pw") { + return $pw_db; + } + return $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); + + + + } + + 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 hashSha1Salted(string $pw, string $pw_db = ''): string + { + if (empty($pw_db)) { + $salt = base64_encode(random_bytes(3)); // 4 char salt. + } else { + $salt = substr(base64_decode(substr($pw_db, 6)), 20); + } + return '{SSHA}' . base64_encode(sha1($pw . $salt, true) . $salt); + } + + 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 hashSha256(string $pw): string + { + return '{SHA256}' . base64_encode(hash('sha256', $pw, true)); + } + + 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); + } +} diff --git a/tests/PacryptTest.php b/tests/PacryptTest.php index 11f5533f..cf92417d 100644 --- a/tests/PacryptTest.php +++ b/tests/PacryptTest.php @@ -1,11 +1,15 @@ assertNotEmpty($hash); $this->assertNotEquals('test', $hash); @@ -68,6 +72,7 @@ class PaCryptTest extends \PHPUnit\Framework\TestCase public function testPacryptDovecot() { global $CONF; + if (!file_exists('/usr/bin/doveadm')) { $this->markTestSkipped("No /usr/bin/doveadm"); } @@ -78,7 +83,6 @@ 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)); // This should also work. @@ -89,7 +93,27 @@ class PaCryptTest extends \PHPUnit\Framework\TestCase $this->assertNotEquals($sha512, _pacrypt_dovecot('foobarbaz', $sha512)); } + public function testPhpCrypt() + { + $config = Config::getInstance(); + Config::write('encrypt', 'php_crypt'); + + + $CONF = Config::getInstance()->getAll(); + + $sha512_crypt = '$6$ijF8bgunALqnEHTo$LHVa6XQBpM5Gt16RMFQuXqrGAS0y0ymaLS8pnkeVUTSx3t2DrGqWwRj6q4ef3V3SWYkb5xkuN9bv7joxNd8kA1'; + + $enc = _pacrypt_php_crypt('foo', $sha512_crypt); + + $this->assertEquals($enc, $sha512_crypt); + + $fail = _pacrypt_php_crypt('bar', $sha512_crypt); + + $this->assertNotEquals($fail, $sha512_crypt); + } + + public function testPhpCryptMd5() { $config = Config::getInstance(); Config::write('encrypt', 'php_crypt:MD5'); @@ -155,29 +179,129 @@ 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 + // should all be from 'test123', generated via dovecot. + $algo_to_example = [ + '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==', - $actualHash = '{SHA512-CRYPT.B64}JDYkM2NWcFM1WFNlUHl5MzdwSiRZWW80d0FmeWg5MXpxcS4uY3dtYUR1Y1RodTJGTDY1NHpXNUNvRU0wT3hXVFFzZkxIZ1JJSTZmT281OVpDUWJOTTF2L0JXajloME0vVjJNbENNMUdwLg=='; + // postfixadmin 'incorrectly' classes sha512.b64 as a sha512-crypted string that's b64 encoded. + // really SHA512.B64 should be base64_encode(hash('sha512', 'something', true)); + 'SHA512.B64' => '{SHA512-CRYPT.B64}JDYkMDBpOFJXQ0JwMlFMMDlobCRFMVFWLzJjbENPbEo4OTg0SjJyY1oxeXNTaFJIYVhJeVdFTDdHRGl3aHliYkhQUHBUQjZTM0lFMlYya2ZXczZWbHY0aDVNa3N0anpud0xuRTBWZVRELw==', + 'CRYPT' => '{CRYPT}$2y$05$ORqzr0AagWr25v3ixHD5QuMXympIoNTbipEFZz6aAmovGNoij2vDO', + 'MD5-CRYPT' => '{MD5-CRYPT}$1$AIjpWveQ$2s3eEAbZiqkJhMYUIVR240', + 'PLAIN-MD5' => '{PLAIN-MD5}cc03e747a6afbbcbf8be7668acfebee5', + 'SSHA' => '{SSHA}ZkqrSEAhvd0FTHaK1IxAQCRa5LWbxGQY', + '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==', + 'SHA256' => '{SHA256}7NcYcNGWMxapfjrDQIyYNa2M8PPBvHA1J8MCZVNPda4=', + 'SHA256-CRYPT' => '{SHA256-CRYPT}$5$CFly6wzfn2az3U8j$EhfQPTdjpMGAisfCjCKektLke5GGEmtdLVaCZSmsKw2', + 'SHA256-CRYPT.B64' => '{SHA256-CRYPT.B64}JDUkUTZZS1ZzZS5sSVJoLndodCR6TWNOUVFVVkhtTmM1ME1SQk9TR3BEeGpRY2M1TzJTQ1lkbWhPN1YxeHlD', + ]; - $check = _pacrypt_sha512_b64('test', $actualHash); + // php 7.3 and below do not support these. + if (phpversion() < '7.3') { + unset($algo_to_example['ARGON2ID']); + unset($algo_to_example['ARGON2ID.B64']); + } - $this->assertTrue(hash_equals($check, $actualHash)); + foreach ($algo_to_example as $algorithm => $example_hash) { + $CONF['encrypt'] = $algorithm; + $pfa_new_hash = pacrypt('test123'); - $str3 = _pacrypt_sha512_b64('foo', ''); + $pacrypt_check = pacrypt('test123', $example_hash); + $pacrypt_sanity = pacrypt('zzzzzzz', $example_hash); - $this->assertNotEmpty($str3); + $this->assertNotEquals($example_hash, $pacrypt_sanity, "Should not match, zzzz password. $algorithm / $pacrypt_sanity"); - $this->assertFalse(hash_equals('test', $str3)); + $this->assertEquals($example_hash, $pacrypt_check, "Should match, algorithm: $algorithm generated:{$pacrypt_check} vs example:{$example_hash}"); - $this->assertTrue(hash_equals(_pacrypt_sha512_b64('foo', $str3), $str3)); + $new_new = pacrypt('test123', $pfa_new_hash); + + $this->assertEquals($pfa_new_hash, $new_new, "Trying: $algorithm => gave: $new_new with $pfa_new_hash ... "); + } + } + + public function testSha512CryptB64() + { + $c = new PFACrypt('SHA512CRYPT.B64'); + + // "SHA512-CRYPT.B64": "{SHA512-CRYPT.B64}JDYkR2JwY3NiZXNMWk9DdERXbiRYdXlhdEZTdy9oa3lyUFE0d24wenpGQTZrSlpTUE9QVWdPcjVRUC40bTRMTjEzdy81aWMvWTdDZllRMWVqSWlhNkd3Q2Z0ZnNjZEFpam9OWjl3OU5tLw==", + $crypt = '{SHA512-CRYPT.B64}JDYkR2JwY3NiZXNMWk9DdERXbiRYdXlhdEZTdy9oa3lyUFE0d24wenpGQTZrSlpTUE9QVWdPcjVRUC40bTRMTjEzdy81aWMvWTdDZllRMWVqSWlhNkd3Q2Z0ZnNjZEFpam9OWjl3OU5tLw=='; + $this->assertEquals($crypt, $c->pacrypt('test123', $crypt)); + } + + public function testWeCopeWithDifferentMethodThanConfigured() + { + $c = new PFACrypt('MD5-CRYPT'); + $md5Crypt = '{MD5-CRYPT}$1$AIjpWveQ$2s3eEAbZiqkJhMYUIVR240'; + + $this->assertEquals($md5Crypt, $c->pacrypt('test123', $md5Crypt)); + + $c = new PFACrypt('SHA1'); + + $this->assertEquals($md5Crypt, $c->pacrypt('test123', $md5Crypt)); + + $sha1Crypt = '{SHA1}cojt0Pw//L6ToM8G41aOKFIWh7w='; + + $this->assertEquals($sha1Crypt, $c->pacrypt('test123', $sha1Crypt)); + } + + public function testSomeCourierHashes() + { + global $CONF; + + $options = [ + 'courier:md5' => '{MD5}zAPnR6avu8v4vnZorP6+5Q==', + 'courier:md5raw' => '{MD5RAW}cc03e747a6afbbcbf8be7668acfebee5', + 'courier:ssha' => '{SSHA}pJTac1QSIHoi0qBPdqnBvgPdjfFtDRVY', + 'courier:sha256' => '{SHA256}7NcYcNGWMxapfjrDQIyYNa2M8PPBvHA1J8MCZVNPda4=', + ]; + + foreach ($options as $algorithm => $example_hash) { + $CONF['encrypt'] = $algorithm; + + $pacrypt_check = pacrypt('test123', $example_hash); + $pacrypt_sanity = pacrypt('zzzzz', $example_hash); + $pfa_new_hash = pacrypt('test123'); + + $this->assertNotEquals($pacrypt_sanity, $pfa_new_hash); + $this->assertNotEquals($pacrypt_sanity, $example_hash); + + $this->assertEquals($example_hash, $pacrypt_check, "Should match, algorithm: $algorithm generated:{$pacrypt_check} vs example:{$example_hash}"); + + $new = pacrypt('test123', $pfa_new_hash); + + $this->assertEquals($new, $pfa_new_hash, "Trying: $algorithm => gave: $new with $pfa_new_hash"); + } + } + + public function testWeSupportWhatWeSayWeDo() + { + foreach (PFACrypt::DOVECOT_NATIVE as $algorithm) { + if (phpversion() < 7.3 && ($algorithm == 'ARGON2ID' || $algorithm == 'ARGON2ID.B64')) { + continue; // needs PHP7.3+ + } + $c = new PFACrypt($algorithm); + $hash1 = $c->pacrypt('test123'); + + $this->assertEquals($hash1, $c->pacrypt('test123', $hash1)); + $this->assertNotEquals($hash1, $c->pacrypt('9999test9999', $hash1)); + } } public function testObviousMechs() @@ -190,13 +314,13 @@ class PaCryptTest extends \PHPUnit\Framework\TestCase 'cleartext' => 'test123', 'mysql_encrypt' => '$6$$KMCDSuWNoVgNrK5P1zDS12ZZt.LV4z9v9NtD0AG0T5Rv/n0wWVvZmHMSKKZQciP7lrqrlbrBrBd4lhBSGy1BU0', 'authlib' => '{md5raw}cc03e747a6afbbcbf8be7668acfebee5', - 'php_crypt:SHA512' => '$6$IeqpXtDIXF09ADdc$IsE.SSK3zuwtS9fdWZ0oVxXQjPDj834xqxTiv3Qfidq3AbAjPb0DNyI28JyzmDVlbfC9uSfNxD9RUyeO1.7FV/', + 'php_crypt:SHA512' => '{SHA512-CRYPT}$6$IeqpXtDIXF09ADdc$IsE.SSK3zuwtS9fdWZ0oVxXQjPDj834xqxTiv3Qfidq3AbAjPb0DNyI28JyzmDVlbfC9uSfNxD9RUyeO1.7FV/', 'php_crypt:DES' => 'VXAXutUnpVYg6', 'php_crypt:MD5' => '$1$rGTbP.KE$wimpECWs/wQa7rnSwCmHU.', 'php_crypt:SHA256' => '$5$UaZs6ZuaLkVPx3bM$4JwAqdphXVutFYw7COgAkp/vj09S1DfjIftxtjqDrr/', 'sha512.b64' => '{SHA512-CRYPT.B64}JDYkMDBpOFJXQ0JwMlFMMDlobCRFMVFWLzJjbENPbEo4OTg0SjJyY1oxeXNTaFJIYVhJeVdFTDdHRGl3aHliYkhQUHBUQjZTM0lFMlYya2ZXczZWbHY0aDVNa3N0anpud0xuRTBWZVRELw==', ]; - + foreach ($mechs as $mech => $example_hash) { if ($mech == 'mysql_encrypt' && Config::read_string('database_type') != 'mysql') { continue;