[FEATURE] Add bcrypt and argon2i password hashes 68/55668/12
authorChristian Futterlieb <christian@futterlieb.ch>
Sun, 11 Feb 2018 20:21:24 +0000 (21:21 +0100)
committerChristian Kuhn <lolli@schwarzbu.ch>
Fri, 11 May 2018 15:37:38 +0000 (17:37 +0200)
Two new ext:saltedpasswords classes implement bcrypt
and argon2i password hashes.

Change-Id: I3acda7f797ee107403662bb3488caaf2f678597d
Relates: #79795
Resolves: #79889
Releases: master
Reviewed-on: https://review.typo3.org/55668
Tested-by: TYPO3com <no-reply@typo3.com>
Reviewed-by: Mathias Schreiber <mathias.schreiber@typo3.com>
Tested-by: Mathias Schreiber <mathias.schreiber@typo3.com>
Reviewed-by: Anja Leichsenring <aleichsenring@ab-softlab.de>
Tested-by: Anja Leichsenring <aleichsenring@ab-softlab.de>
Reviewed-by: Christian Kuhn <lolli@schwarzbu.ch>
Tested-by: Christian Kuhn <lolli@schwarzbu.ch>
typo3/sysext/core/Documentation/Changelog/master/Feature-79889-SaltedpasswordsSupportPHPPasswordAPI.rst [new file with mode: 0644]
typo3/sysext/saltedpasswords/Classes/Exception/InvalidSaltException.php [new file with mode: 0644]
typo3/sysext/saltedpasswords/Classes/Salt/Argon2iSalt.php [new file with mode: 0644]
typo3/sysext/saltedpasswords/Classes/Salt/BcryptSalt.php [new file with mode: 0644]
typo3/sysext/saltedpasswords/Classes/Salt/SaltFactory.php
typo3/sysext/saltedpasswords/Documentation/Overview/Index.rst
typo3/sysext/saltedpasswords/Resources/Private/Language/locallang.xlf
typo3/sysext/saltedpasswords/Tests/Unit/Salt/Argon2iSaltTest.php [new file with mode: 0644]
typo3/sysext/saltedpasswords/Tests/Unit/Salt/BcryptSaltTest.php [new file with mode: 0644]
typo3/sysext/saltedpasswords/Tests/Unit/Salt/SaltFactoryTest.php

diff --git a/typo3/sysext/core/Documentation/Changelog/master/Feature-79889-SaltedpasswordsSupportPHPPasswordAPI.rst b/typo3/sysext/core/Documentation/Changelog/master/Feature-79889-SaltedpasswordsSupportPHPPasswordAPI.rst
new file mode 100644 (file)
index 0000000..52de18e
--- /dev/null
@@ -0,0 +1,24 @@
+.. include:: ../../Includes.txt
+
+===========================================================
+Feature: #79889 - Saltedpasswords supports PHP password API
+===========================================================
+
+See :issue:`79889`
+
+Description
+===========
+
+Salted passwords now supports the PHP Password Hashing API: https://secure.php.net/manual/en/ref.password.php
+
+The two hash algorithms `bcrypt` and `argon2i` are available and can be selected in the
+settings of the salted passwords extension if the PHP instance supports them.
+
+Impact
+======
+
+None. You can start to use the new password hashing methods by selecting "Standard PHP password hashing (bcrypt)"
+or "Standard PHP password hashing (argon2i)" in Extension Manager Configuration of saltedpasswords. Password
+hashes of existing users will be updated as soon as users log in.
+
+.. index:: Backend, Frontend, PHP-API
diff --git a/typo3/sysext/saltedpasswords/Classes/Exception/InvalidSaltException.php b/typo3/sysext/saltedpasswords/Classes/Exception/InvalidSaltException.php
new file mode 100644 (file)
index 0000000..5f15c9b
--- /dev/null
@@ -0,0 +1,23 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Saltedpasswords\Exception;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+/**
+ * InvalidSaltException thrown if salting went wrong.
+ */
+class InvalidSaltException extends \Exception
+{
+}
diff --git a/typo3/sysext/saltedpasswords/Classes/Salt/Argon2iSalt.php b/typo3/sysext/saltedpasswords/Classes/Salt/Argon2iSalt.php
new file mode 100644 (file)
index 0000000..a5d2bdf
--- /dev/null
@@ -0,0 +1,186 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Saltedpasswords\Salt;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use TYPO3\CMS\Saltedpasswords\Exception\InvalidSaltException;
+
+/**
+ * This class implements the 'argon2i' flavour of the php password api.
+ *
+ * Hashes are identified by the prefix '$argon2i$'.
+ *
+ * The length of a argon2i password hash (in the form it is received from
+ * PHP) depends on the environment.
+ *
+ * @see PASSWORD_ARGON2I in https://secure.php.net/manual/en/password.constants.php
+ */
+class Argon2iSalt implements SaltInterface
+{
+    /**
+     * Prefix for the password hash.
+     */
+    protected const PREFIX = '$argon2i$';
+
+    /**
+     * The PHP defaults are rather low ('memory_cost' => 1024, 'time_cost' => 2, 'threads' => 2)
+     * We raise that significantly by default. At the time of this writing, with the options
+     * below, password_verify() needs about 130ms on an I7 6820 on 2 CPU's.
+     *
+     * Note the default values are set again in 'setOptions' below if needed.
+     *
+     * @var array
+     */
+    protected $options = [
+        'memory_cost' => 16384,
+        'time_cost' => 16,
+        'threads' => 2
+    ];
+
+    /**
+     * Checks if a given plaintext password is correct by comparing it with
+     * a given salted hashed password.
+     *
+     * @param string $plainPW plain text password to compare with salted hash
+     * @param string $saltedHashPW Salted hash to compare plain-text password with
+     * @return bool TRUE, if plaintext password is correct, otherwise FALSE
+     */
+    public function checkPassword(string $plainPW, string $saltedHashPW): bool
+    {
+        return password_verify($plainPW, $saltedHashPW);
+    }
+
+    /**
+     * Returns true if PHP is compiled '--with-password-argon2' so
+     * the hash algorithm is available.
+     *
+     * @return bool
+     */
+    public function isAvailable(): bool
+    {
+        return defined('PASSWORD_ARGON2I')
+            && PASSWORD_BCRYPT;
+    }
+
+    /**
+     * Creates a salted hash for a given plaintext password
+     *
+     * @param string $password Plaintext password to create a salted hash from
+     * @param string $salt Optional custom salt to use
+     * @return string|null Salted hashed password
+     */
+    public function getHashedPassword(string $password, string $salt = null)
+    {
+        if ($salt !== null) {
+            trigger_error(static::class . ': using a custom salt is deprecated in PHP password api and ignored', E_USER_DEPRECATED);
+        }
+        $hashedPassword = null;
+        if ($password !== '') {
+            $hashedPassword = password_hash($password, PASSWORD_ARGON2I, $this->options);
+            if (!is_string($hashedPassword) || empty($hashedPassword)) {
+                throw new InvalidSaltException('Cannot generate password, probably invalid options', 1526052118);
+            }
+        }
+        return $hashedPassword;
+    }
+
+    /**
+     * Checks whether a user's hashed password needs to be replaced with a new hash,
+     * for instance if options changed.
+     *
+     * @param string $passString Salted hash to check if it needs an update
+     * @return bool TRUE if salted hash needs an update, otherwise FALSE
+     */
+    public function isHashUpdateNeeded(string $passString): bool
+    {
+        return password_needs_rehash($passString, PASSWORD_ARGON2I, $this->options);
+    }
+
+    /**
+     * Determines if a given string is a valid salted hashed password.
+     *
+     * @param string $saltedPW String to check
+     * @return bool TRUE if it's valid salted hashed password, otherwise FALSE
+     */
+    public function isValidSaltedPW(string $saltedPW): bool
+    {
+        $result = false;
+        $passwordInfo = password_get_info($saltedPW);
+        if (isset($passwordInfo['algo'])
+            && $passwordInfo['algo'] === PASSWORD_ARGON2I
+            && strncmp($saltedPW, static::PREFIX, strlen(static::PREFIX)) === 0
+        ) {
+            $result = true;
+        }
+        return $result;
+    }
+
+    /**
+     * @return array
+     */
+    public function getOptions(): array
+    {
+        return $this->options;
+    }
+
+    /**
+     * Set new memory_cost, time_cost, and thread values.
+     *
+     * @param array $options
+     */
+    public function setOptions(array $options): void
+    {
+        $newOptions = [];
+
+        // Check options for validity, else use hard coded defaults
+        if (isset($options['memory_cost'])) {
+            if ((int)$options['memory_cost'] < PASSWORD_ARGON2_DEFAULT_MEMORY_COST) {
+                throw new \InvalidArgumentException(
+                    'memory_cost must not be lower than ' . PASSWORD_ARGON2_DEFAULT_MEMORY_COST,
+                    1526042080
+                );
+            }
+            $newOptions['memory_cost'] = (int)$options['memory_cost'];
+        } else {
+            $newOptions['memory_cost'] = 16384;
+        }
+
+        if (isset($options['time_cost'])) {
+            if ((int)$options['time_cost'] < PASSWORD_ARGON2_DEFAULT_TIME_COST) {
+                throw new \InvalidArgumentException(
+                    'time_cost must not be lower than ' . PASSWORD_ARGON2_DEFAULT_TIME_COST,
+                    1526042081
+                );
+            }
+            $newOptions['time_cost'] = (int)$options['time_cost'];
+        } else {
+            $newOptions['time_cost'] = 16;
+        }
+
+        if (isset($options['threads'])) {
+            if ((int)$options['threads'] < PASSWORD_ARGON2_DEFAULT_THREADS) {
+                throw new \InvalidArgumentException(
+                    'threads must not be lower than ' . PASSWORD_ARGON2_DEFAULT_THREADS,
+                    1526042082
+                );
+            }
+            $newOptions['threads'] = (int)$options['threads'];
+        } else {
+            $newOptions['threads'] = 2;
+        }
+
+        $this->options = $newOptions;
+    }
+}
diff --git a/typo3/sysext/saltedpasswords/Classes/Salt/BcryptSalt.php b/typo3/sysext/saltedpasswords/Classes/Salt/BcryptSalt.php
new file mode 100644 (file)
index 0000000..30ad1b1
--- /dev/null
@@ -0,0 +1,190 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Saltedpasswords\Salt;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use TYPO3\CMS\Saltedpasswords\Exception\InvalidSaltException;
+
+/**
+ * This class implements the 'bcrypt' flavour of the php password api.
+ *
+ * Hashes are identified by the prefix '$2y$'.
+ *
+ * To workaround the limitations of bcrypt (accepts not more than 72
+ * chars and truncates on NUL bytes), the plain password is pre-hashed
+ * before the actual password-hash is generated/verified.
+ *
+ * @see PASSWORD_BCRYPT in https://secure.php.net/manual/en/password.constants.php
+ */
+class BcryptSalt implements SaltInterface
+{
+    /**
+     * Prefix for the password hash
+     */
+    protected const PREFIX = '$2y$';
+
+    /**
+     * Raise default PHP cost (10). At the time of this writing, this leads to
+     * 150-200ms computing time on a casual I7 CPU.
+     *
+     * Note the default values are set again in 'setOptions' below if needed.
+     *
+     * @var array
+     */
+    protected $options = [
+        'cost' => 12,
+    ];
+
+    /**
+     * Returns true if sha384 for pre-hashing and bcrypt itself is available.
+     *
+     * @return bool
+     */
+    public function isAvailable(): bool
+    {
+        return defined('PASSWORD_BCRYPT')
+            && PASSWORD_BCRYPT
+            && function_exists('hash')
+            && function_exists('hash_algos')
+            && in_array('sha384', hash_algos());
+    }
+
+    /**
+     * Checks if a given plaintext password is correct by comparing it with
+     * a given salted hashed password.
+     *
+     * @return bool
+     */
+    public function checkPassword(string $plainPW, string $saltedHashPW): bool
+    {
+        return password_verify($this->processPlainPassword($plainPW), $saltedHashPW);
+    }
+
+    /**
+     * Extend parent method to workaround bcrypt limitations.
+     *
+     * @see \TYPO3\CMS\Saltedpasswords\Salt\BcryptSalt::processPlainPassword()
+     * @param string $password Plaintext password to create a salted hash from
+     * @param string $salt Optional custom salt to use
+     * @return string Salted hashed password
+     */
+    public function getHashedPassword(string $password, string $salt = null)
+    {
+        if ($salt !== null) {
+            trigger_error(static::class . ': using a custom salt is deprecated in PHP password api and thus ignored', E_USER_DEPRECATED);
+        }
+        $hashedPassword = null;
+        if ($password !== '') {
+            $password = $this->processPlainPassword($password);
+            $hashedPassword = password_hash($password, PASSWORD_BCRYPT, $this->options);
+            if (!is_string($hashedPassword) || empty($hashedPassword)) {
+                throw new InvalidSaltException('Cannot generate password, probably invalid options', 1517174114);
+            }
+        }
+        return $hashedPassword;
+    }
+
+    /**
+     * The plain password is processed through sha384 and then base64
+     * encoded. This will produce a 64 characters input to use with
+     * password_* functions, which has some advantages:
+     * 1. It is close to the (bcrypt-) maximum of 72 character keyspace
+     * 2. base64 will never produce NUL bytes (bcrypt truncates on NUL bytes)
+     * 3. sha384 is resistant to length extension attacks
+     *
+     * @param string $password
+     * @return string
+     */
+    protected function processPlainPassword(string $password): string
+    {
+        return base64_encode(hash('sha384', $password, true));
+    }
+
+    /**
+     * Determines if a given string is a valid salted hashed password.
+     *
+     * @param string $saltedPW String to check
+     * @return bool TRUE if it's valid salted hashed password, otherwise FALSE
+     */
+    public function isValidSaltedPW(string $saltedPW): bool
+    {
+        $result = false;
+        $passwordInfo = password_get_info($saltedPW);
+        // Validate the cost value, password_get_info() does not check it
+        $cost = (int)substr($saltedPW, 4, 2);
+        if (isset($passwordInfo['algo'])
+            && $passwordInfo['algo'] === PASSWORD_BCRYPT
+            && strncmp($saltedPW, static::PREFIX, strlen(static::PREFIX)) === 0
+            && $this->isValidBcryptCost($cost)
+        ) {
+            $result = true;
+        }
+        return $result;
+    }
+
+    /**
+     * Checks whether a user's hashed password needs to be replaced with a new hash.
+     *
+     * @param string $passString Salted hash to check if it needs an update
+     * @return bool TRUE if salted hash needs an update, otherwise FALSE
+     */
+    public function isHashUpdateNeeded(string $passString): bool
+    {
+        return password_needs_rehash($passString, PASSWORD_BCRYPT, $this->options);
+    }
+
+    /**
+     * @return array
+     */
+    public function getOptions(): array
+    {
+        return $this->options;
+    }
+
+    /**
+     * Set new memory_cost, time_cost, and thread values.
+     *
+     * @param array $options
+     */
+    public function setOptions(array $options): void
+    {
+        $newOptions = [];
+
+        // Check options for validity, else use hard coded defaults
+        if (isset($options['cost'])) {
+            if (!$this->isValidBcryptCost((int)$options['cost'])) {
+                throw new \InvalidArgumentException(
+                    'cost must not be lower than ' . PASSWORD_BCRYPT_DEFAULT_COST . ' or higher than 31',
+                    1526042084
+                );
+            }
+            $newOptions['cost'] = (int)$options['cost'];
+        } else {
+            $newOptions['cost'] = 12;
+        }
+
+        $this->options = $newOptions;
+    }
+
+    /**
+     * @see https://github.com/php/php-src/blob/php-7.2.0/ext/standard/password.c#L441-L444
+     * @param int $cost
+     * @return bool
+     */
+    protected function isValidBcryptCost(int $cost): bool
+    {
+        return $cost >= PASSWORD_BCRYPT_DEFAULT_COST && $cost <= 31;
+    }
+}
index 1ec6fb5..b78a7de 100644 (file)
@@ -68,7 +68,9 @@ class SaltFactory
             Md5Salt::class => Md5Salt::class,
             BlowfishSalt::class => BlowfishSalt::class,
             PhpassSalt::class => PhpassSalt::class,
-            Pbkdf2Salt::class => Pbkdf2Salt::class
+            Pbkdf2Salt::class => Pbkdf2Salt::class,
+            BcryptSalt::class => BcryptSalt::class,
+            Argon2iSalt::class => Argon2iSalt::class,
         ];
     }
 
index 3a4fc46..aed965a 100644 (file)
@@ -67,6 +67,20 @@ The extension provides several types of hashing method:
   comparison to MD5 salted hashing. Use this setting if you have higher
   requirements on password security. This requires a PHP > 5.5.0.
 
+- **Standard PHP password hashing (bcrypt)** This method
+  implements the `PHP password hashing API
+  <https://secure.php.net/manual/en/book.password.php>`_ with the bcrypt
+  algorithm. It can be considered very secure and is recommended to be
+  used as default password hashing method.
+
+- **Standard PHP password hashing (argon2i)** This method
+  implements the `PHP password hashing API
+  <https://secure.php.net/manual/en/book.password.php>`_ with the argon2i
+  algorithm, winner of the `Password Hashing Competition
+  <https://password-hashing.net/>`_. It is the future "standard" for
+  password hashing. Please check your system whether argon2i is available
+  to you.
+
 
 .. _server-environment:
 
index dec4e90..520f6a2 100644 (file)
                        <trans-unit id="ext.saltedpasswords.title.pbkdf2salt">
                                <source>PBKDF2 key derivation (advanced)</source>
                        </trans-unit>
+                       <trans-unit id="ext.saltedpasswords.title.bcryptsalt">
+                               <source>Bcrypt password hashing (PHP native)</source>
+                       </trans-unit>
+                       <trans-unit id="ext.saltedpasswords.title.argon2isalt">
+                               <source>Argon2i password hashing (PHP native)</source>
+                       </trans-unit>
                        <trans-unit id="ext.saltedpasswords.tasks.bulkupdate.name">
                                <source>Convert user passwords to salted hashes</source>
                        </trans-unit>
diff --git a/typo3/sysext/saltedpasswords/Tests/Unit/Salt/Argon2iSaltTest.php b/typo3/sysext/saltedpasswords/Tests/Unit/Salt/Argon2iSaltTest.php
new file mode 100644 (file)
index 0000000..5768517
--- /dev/null
@@ -0,0 +1,236 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Saltedpasswords\Tests\Unit\Salt;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use TYPO3\CMS\Saltedpasswords\Salt\Argon2iSalt;
+use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
+
+/**
+ * Test case
+ */
+class Argon2iSaltTest extends UnitTestCase
+{
+    /**
+     * @var Argon2iSalt
+     */
+    protected $subject;
+
+    /**
+     * Sets up the fixtures for this testcase.
+     */
+    protected function setUp()
+    {
+        $this->subject = new Argon2iSalt();
+        // Set low values to speed up tests
+        $this->subject->setOptions([
+            'memory_cost' => 1024,
+            'time_cost' => 2,
+            'threads' => 2,
+        ]);
+    }
+
+    /**
+     * @test
+     */
+    public function getOptionsReturnsPreviouslySetOptions()
+    {
+        $options = [
+            'memory_cost' => 2048,
+            'time_cost' => 4,
+            'threads' => 4,
+        ];
+        $this->subject->setOptions($options);
+        $this->assertSame($this->subject->getOptions(), $options);
+    }
+
+    /**
+     * @test
+     */
+    public function setOptionsThrowsExceptionWithTooLowMemoryCost()
+    {
+        $this->expectException(\InvalidArgumentException::class);
+        $this->expectExceptionCode(1526042080);
+        $this->subject->setOptions(['memory_cost' => 1]);
+    }
+
+    /**
+     * @test
+     */
+    public function setOptionsThrowsExceptionWithTooLowTimeCost()
+    {
+        $this->expectException(\InvalidArgumentException::class);
+        $this->expectExceptionCode(1526042081);
+        $this->subject->setOptions(['time_cost' => 1]);
+    }
+
+    /**
+     * @test
+     */
+    public function setOptionsThrowsExceptionWithTooLowThreads()
+    {
+        $this->expectException(\InvalidArgumentException::class);
+        $this->expectExceptionCode(1526042082);
+        $this->subject->setOptions(['threads' => 1]);
+    }
+
+    /**
+     * @test
+     */
+    public function getHashedPasswordReturnsNullOnEmptyPassword()
+    {
+        $this->assertNull($this->subject->getHashedPassword(''));
+    }
+
+    /**
+     * @test
+     */
+    public function getHashedPasswordReturnsString()
+    {
+        $hash = $this->subject->getHashedPassword('password');
+        $this->assertNotNull($hash);
+        $this->assertEquals('string', gettype($hash));
+    }
+
+    /**
+     * @test
+     */
+    public function isValidSaltedPwValidatesHastCreatedByGetHashedPassword()
+    {
+        $hash = $this->subject->getHashedPassword('password');
+        $this->assertTrue($this->subject->isValidSaltedPW($hash));
+    }
+
+    /**
+     * Tests authentication procedure with alphabet characters.
+     *
+     * @test
+     */
+    public function checkPasswordReturnsTrueForHashedPasswordWithValidAlphaCharClassPassword()
+    {
+        $password = 'aEjOtY';
+        $hash = $this->subject->getHashedPassword($password);
+        $this->assertTrue($this->subject->checkPassword($password, $hash));
+    }
+
+    /**
+     * Tests authentication procedure with numeric characters.
+     *
+     * @test
+     */
+    public function checkPasswordReturnsTrueForHashedPasswordWithValidNumericCharClassPassword()
+    {
+        $password = '01369';
+        $hash = $this->subject->getHashedPassword($password);
+        $this->assertTrue($this->subject->checkPassword($password, $hash));
+    }
+
+    /**
+     * Tests authentication procedure with US-ASCII special characters.
+     *
+     * @test
+     */
+    public function checkPasswordReturnsTrueForHashedPasswordWithValidAsciiSpecialCharClassPassword()
+    {
+        $password = ' !"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~';
+        $hash = $this->subject->getHashedPassword($password);
+        $this->assertTrue($this->subject->checkPassword($password, $hash));
+    }
+
+    /**
+     * Tests authentication procedure with latin1 special characters.
+     *
+     * @test
+     */
+    public function checkPasswordReturnsTrueForHashedPasswordWithValidLatin1SpecialCharClassPassword()
+    {
+        $password = '';
+        for ($i = 160; $i <= 191; $i++) {
+            $password .= chr($i);
+        }
+        $password .= chr(215) . chr(247);
+        $hash = $this->subject->getHashedPassword($password);
+        $this->assertTrue($this->subject->checkPassword($password, $hash));
+    }
+
+    /**
+     * Tests authentication procedure with latin1 umlauts.
+     *
+     * @test
+     */
+    public function checkPasswordReturnsTrueForHashedPasswordWithValidLatin1UmlautCharClassPassword()
+    {
+        $password = '';
+        for ($i = 192; $i <= 255; $i++) {
+            if ($i === 215 || $i === 247) {
+                // skip multiplication sign (×) and obelus (÷)
+                continue;
+            }
+            $password .= chr($i);
+        }
+        $hash = $this->subject->getHashedPassword($password);
+        $this->assertTrue($this->subject->checkPassword($password, $hash));
+    }
+
+    /**
+     * @test
+     */
+    public function checkPasswordReturnsTrueForHashedPasswordWithNonValidPassword()
+    {
+        $password = 'password';
+        $password1 = $password . 'INVALID';
+        $hash = $this->subject->getHashedPassword($password);
+        $this->assertFalse($this->subject->checkPassword($password1, $hash));
+    }
+
+    /**
+     * @test
+     */
+    public function isHashUpdateNeededReturnsFalseForJustGeneratedHash()
+    {
+        $password = 'password';
+        $hash = $this->subject->getHashedPassword($password);
+        $this->assertFalse($this->subject->isHashUpdateNeeded($hash));
+    }
+
+    /**
+     * @test
+     */
+    public function isHashUpdateNeededReturnsTrueForHashGeneratedWithOldOptions()
+    {
+        $originalOptions = $this->subject->getOptions();
+        $hash = $this->subject->getHashedPassword('password');
+
+        $newOptions = $originalOptions;
+        $newOptions['memory_cost'] = $newOptions['memory_cost'] + 1;
+        $this->subject->setOptions($newOptions);
+        $this->assertTrue($this->subject->isHashUpdateNeeded($hash));
+        $this->subject->setOptions($originalOptions);
+
+        // Change $timeCost
+        $newOptions = $originalOptions;
+        $newOptions['time_cost'] = $newOptions['time_cost'] + 1;
+        $this->subject->setOptions($newOptions);
+        $this->assertTrue($this->subject->isHashUpdateNeeded($hash));
+        $this->subject->setOptions($originalOptions);
+
+        // Change $threads
+        $newOptions = $originalOptions;
+        $newOptions['threads'] = $newOptions['threads'] + 1;
+        $this->subject->setOptions($newOptions);
+        $this->assertTrue($this->subject->isHashUpdateNeeded($hash));
+        $this->subject->setOptions($originalOptions);
+    }
+}
diff --git a/typo3/sysext/saltedpasswords/Tests/Unit/Salt/BcryptSaltTest.php b/typo3/sysext/saltedpasswords/Tests/Unit/Salt/BcryptSaltTest.php
new file mode 100644 (file)
index 0000000..c82b5b1
--- /dev/null
@@ -0,0 +1,223 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Saltedpasswords\Tests\Unit\Salt;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use TYPO3\CMS\Saltedpasswords\Salt\BcryptSalt;
+use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
+
+/**
+ * Test case
+ */
+class BcryptSaltTest extends UnitTestCase
+{
+    /**
+     * @var BcryptSalt
+     */
+    protected $subject;
+
+    /**
+     * Sets up the fixtures for this testcase.
+     */
+    protected function setUp()
+    {
+        $this->subject = new BcryptSalt();
+        // Set a low cost to speed up tests
+        $this->subject->setOptions([
+            'cost' => 10,
+        ]);
+    }
+
+    /**
+     * @test
+     */
+    public function getOptionsReturnsPreviouslySetOptions()
+    {
+        $options = [
+            'cost' => 11,
+        ];
+        $this->subject->setOptions($options);
+        $this->assertSame($this->subject->getOptions(), $options);
+    }
+
+    /**
+     * @test
+     */
+    public function setOptionsThrowsExceptionOnTooLowCostValue()
+    {
+        $this->expectException(\InvalidArgumentException::class);
+        $this->expectExceptionCode(1526042084);
+        $this->subject->setOptions(['cost' => 9]);
+    }
+
+    /**
+     * @test
+     */
+    public function getHashedPasswordReturnsNullOnEmptyPassword()
+    {
+        $this->assertNull($this->subject->getHashedPassword(''));
+    }
+
+    /**
+     * @test
+     */
+    public function getHashedPasswordReturnsString()
+    {
+        $hash = $this->subject->getHashedPassword('password');
+        $this->assertNotNull($hash);
+        $this->assertEquals('string', gettype($hash));
+    }
+
+    /**
+     * @test
+     */
+    public function isValidSaltedPwValidatesHastCreatedByGetHashedPassword()
+    {
+        $hash = $this->subject->getHashedPassword('password');
+        $this->assertTrue($this->subject->isValidSaltedPW($hash));
+    }
+
+    /**
+     * Tests authentication procedure with alphabet characters.
+     *
+     * @test
+     */
+    public function checkPasswordReturnsTrueForHashedPasswordWithValidAlphaCharClassPassword()
+    {
+        $password = 'aEjOtY';
+        $hash = $this->subject->getHashedPassword($password);
+        $this->assertTrue($this->subject->checkPassword($password, $hash));
+    }
+
+    /**
+     * Tests authentication procedure with numeric characters.
+     *
+     * @test
+     */
+    public function checkPasswordReturnsTrueForHashedPasswordWithValidNumericCharClassPassword()
+    {
+        $password = '01369';
+        $hash = $this->subject->getHashedPassword($password);
+        $this->assertTrue($this->subject->checkPassword($password, $hash));
+    }
+
+    /**
+     * Tests authentication procedure with US-ASCII special characters.
+     *
+     * @test
+     */
+    public function checkPasswordReturnsTrueForHashedPasswordWithValidAsciiSpecialCharClassPassword()
+    {
+        $password = ' !"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~';
+        $hash = $this->subject->getHashedPassword($password);
+        $this->assertTrue($this->subject->checkPassword($password, $hash));
+    }
+
+    /**
+     * Tests authentication procedure with latin1 special characters.
+     *
+     * @test
+     */
+    public function checkPasswordReturnsTrueForHashedPasswordWithValidLatin1SpecialCharClassPassword()
+    {
+        $password = '';
+        for ($i = 160; $i <= 191; $i++) {
+            $password .= chr($i);
+        }
+        $password .= chr(215) . chr(247);
+        $hash = $this->subject->getHashedPassword($password);
+        $this->assertTrue($this->subject->checkPassword($password, $hash));
+    }
+
+    /**
+     * Tests authentication procedure with latin1 umlauts.
+     *
+     * @test
+     */
+    public function checkPasswordReturnsTrueForHashedPasswordWithValidLatin1UmlautCharClassPassword()
+    {
+        $password = '';
+        for ($i = 192; $i <= 255; $i++) {
+            if ($i === 215 || $i === 247) {
+                // skip multiplication sign (×) and obelus (÷)
+                continue;
+            }
+            $password .= chr($i);
+        }
+        $hash = $this->subject->getHashedPassword($password);
+        $this->assertTrue($this->subject->checkPassword($password, $hash));
+    }
+
+    /**
+     * @test
+     */
+    public function checkPasswordReturnsTrueForHashedPasswordWithNonValidPassword()
+    {
+        $password = 'password';
+        $password1 = $password . 'INVALID';
+        $hash = $this->subject->getHashedPassword($password);
+        $this->assertFalse($this->subject->checkPassword($password1, $hash));
+    }
+
+    /**
+     * @test
+     */
+    public function isHashUpdateNeededReturnsFalseForJustGeneratedHash()
+    {
+        $hash = $this->subject->getHashedPassword('password');
+        $this->assertFalse($this->subject->isHashUpdateNeeded($hash));
+    }
+
+    /**
+     * @test
+     */
+    public function isHashUpdateNeededReturnsTrueForHashGeneratedWithOldOptions()
+    {
+        $originalOptions = $this->subject->getOptions();
+        $hash = $this->subject->getHashedPassword('password');
+
+        $newOptions = $originalOptions;
+        $newOptions['cost'] = $newOptions['cost'] + 1;
+        $this->subject->setOptions($newOptions);
+        $this->assertTrue($this->subject->isHashUpdateNeeded($hash));
+    }
+
+    /**
+     * Bcrypt truncates on NUL characters by default
+     *
+     * @test
+     */
+    public function getHashedPasswordDoesNotTruncateOnNul()
+    {
+        $password1 = 'pass' . "\x00" . 'word';
+        $password2 = 'pass' . "\x00" . 'phrase';
+        $hash = $this->subject->getHashedPassword($password1);
+        $this->assertFalse($this->subject->checkPassword($password2, $hash));
+    }
+
+    /**
+     * Bcrypt truncates after 72 characters by default
+     *
+     * @test
+     */
+    public function getHashedPasswordDoesNotTruncateAfter72Chars()
+    {
+        $prefix = str_repeat('a', 72);
+        $password1 = $prefix . 'one';
+        $password2 = $prefix . 'two';
+        $hash = $this->subject->getHashedPassword($password1);
+        $this->assertFalse($this->subject->checkPassword($password2, $hash));
+    }
+}
index 281b9fb..24d3c84 100644 (file)
@@ -137,6 +137,26 @@ class SaltFactoryTest extends \TYPO3\TestingFramework\Core\Unit\UnitTestCase
     /**
      * @test
      */
+    public function objectInstanceForPhpPasswordHashBcryptSalts()
+    {
+        $saltBcrypt = '$2y$12$Tz.al0seuEgRt61u0bzqAOWu67PgG2ThG25oATJJ0oS5KLCPCgBOe';
+        $this->objectInstance = \TYPO3\CMS\Saltedpasswords\Salt\SaltFactory::getSaltingInstance($saltBcrypt);
+        $this->assertInstanceOf(\TYPO3\CMS\Saltedpasswords\Salt\BcryptSalt::class, $this->objectInstance);
+    }
+
+    /**
+     * @test
+     */
+    public function objectInstanceForPhpPasswordHashArgon2iSalts()
+    {
+        $saltArgon2i = '$argon2i$v=19$m=8,t=1,p=1$djZiNkdEa3lOZm1SSmZsdQ$9iiRjpLZAT7kfHwS1xU9cqSU7+nXy275qpB/eKjI1ig';
+        $this->objectInstance = \TYPO3\CMS\Saltedpasswords\Salt\SaltFactory::getSaltingInstance($saltArgon2i);
+        $this->assertInstanceOf(\TYPO3\CMS\Saltedpasswords\Salt\Argon2iSalt::class, $this->objectInstance);
+    }
+
+    /**
+     * @test
+     */
     public function resettingFactoryInstanceSucceeds()
     {
         $defaultClassNameToUse = \TYPO3\CMS\Saltedpasswords\Utility\SaltedPasswordsUtility::getDefaultSaltingHashingMethod();