Commit fec959d3 authored by Christian Futterlieb's avatar Christian Futterlieb Committed by Christian Kuhn
Browse files

[FEATURE] Add bcrypt and argon2i password hashes

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: default avatarTYPO3com <no-reply@typo3.com>
Reviewed-by: default avatarMathias Schreiber <mathias.schreiber@typo3.com>
Tested-by: default avatarMathias Schreiber <mathias.schreiber@typo3.com>
Reviewed-by: Anja Leichsenring's avatarAnja Leichsenring <aleichsenring@ab-softlab.de>
Tested-by: Anja Leichsenring's avatarAnja Leichsenring <aleichsenring@ab-softlab.de>
Reviewed-by: Christian Kuhn's avatarChristian Kuhn <lolli@schwarzbu.ch>
Tested-by: Christian Kuhn's avatarChristian Kuhn <lolli@schwarzbu.ch>
parent b177511c
.. 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
<?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
{
}
<?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;
}
}
<?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;
}
}
......@@ -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,
];
}
......
......@@ -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:
......
......@@ -24,6 +24,12 @@
<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>
......
<?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);