[Security] Add Argon2idPasswordEncoder

This commit is contained in:
Robin Chalas 2018-12-17 15:40:40 +01:00
parent ca290396fb
commit 0c82173b24
17 changed files with 430 additions and 50 deletions

View File

@ -145,8 +145,17 @@ Security
}
```
* Using `Argon2iPasswordEncoder` while only the `argon2id` algorithm is supported
is deprecated, use `Argon2idPasswordEncoder` instead
SecurityBundle
--------------
* Configuring encoders using `argon2i` as algorithm while only `argon2id` is
supported is deprecated, use `argon2id` instead
TwigBridge
==========
----------
* deprecated the `$requestStack` and `$requestContext` arguments of the
`HttpFoundationExtension`, pass a `Symfony\Component\HttpFoundation\UrlHelper`

View File

@ -323,6 +323,9 @@ Security
}
```
* Using `Argon2iPasswordEncoder` while only the `argon2id` algorithm is supported
now throws a \LogicException`, use `Argon2idPasswordEncoder` instead
SecurityBundle
--------------
@ -342,6 +345,8 @@ SecurityBundle
changed to underscores.
Before: `my-cookie` deleted the `my_cookie` cookie (with an underscore).
After: `my-cookie` deletes the `my-cookie` cookie (with a dash).
* Configuring encoders using `argon2i` as algorithm while only `argon2id` is supported
now throws a `\LogicException`, use `argon2id` instead
Serializer
----------

View File

@ -8,6 +8,9 @@ CHANGELOG
option is deprecated and will be disabled in Symfony 5.0. This affects to cookies
with dashes in their names. For example, starting from Symfony 5.0, the `my-cookie`
name will delete `my-cookie` (with a dash) instead of `my_cookie` (with an underscore).
* Deprecated configuring encoders using `argon2i` as algorithm while only `argon2id` is supported,
use `argon2id` instead
4.2.0
-----

View File

@ -29,6 +29,7 @@ use Symfony\Component\DependencyInjection\Parameter;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
use Symfony\Component\Security\Core\Encoder\Argon2idPasswordEncoder;
use Symfony\Component\Security\Core\Encoder\Argon2iPasswordEncoder;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Http\Controller\UserValueResolver;
@ -570,6 +571,8 @@ class SecurityExtension extends Extension implements PrependExtensionInterface
}
throw new InvalidConfigurationException('Argon2i algorithm is not supported. Install the libsodium extension or use BCrypt instead.');
} elseif (\defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13')) {
@trigger_error('Configuring an encoder based on the "argon2i" algorithm while only "argon2id" is supported is deprecated since Symfony 4.3, use "argon2id" instead.', E_USER_DEPRECATED);
}
return [
@ -582,6 +585,22 @@ class SecurityExtension extends Extension implements PrependExtensionInterface
];
}
// Argon2id encoder
if ('argon2id' === $config['algorithm']) {
if (!Argon2idPasswordEncoder::isSupported()) {
throw new InvalidConfigurationException('Argon2i algorithm is not supported. Install the libsodium extension or use BCrypt instead.');
}
return [
'class' => Argon2idPasswordEncoder::class,
'arguments' => [
$config['memory_cost'],
$config['time_cost'],
$config['threads'],
],
];
}
// run-time configured encoder
return $config;
}

View File

@ -18,6 +18,7 @@ use Symfony\Component\DependencyInjection\Argument\IteratorArgument;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\Security\Core\Authorization\AccessDecisionManager;
use Symfony\Component\Security\Core\Encoder\Argon2idPasswordEncoder;
use Symfony\Component\Security\Core\Encoder\Argon2iPasswordEncoder;
abstract class CompleteConfigurationTest extends TestCase
@ -313,7 +314,7 @@ abstract class CompleteConfigurationTest extends TestCase
public function testEncodersWithLibsodium()
{
if (!Argon2iPasswordEncoder::isSupported()) {
if (!Argon2iPasswordEncoder::isSupported() || \defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13')) {
$this->markTestSkipped('Argon2i algorithm is not supported.');
}
@ -364,6 +365,59 @@ abstract class CompleteConfigurationTest extends TestCase
]], $container->getDefinition('security.encoder_factory.generic')->getArguments());
}
public function testEncodersWithArgon2id()
{
if (!Argon2idPasswordEncoder::isSupported()) {
$this->markTestSkipped('Argon2i algorithm is not supported.');
}
$container = $this->getContainer('argon2id_encoder');
$this->assertEquals([[
'JMS\FooBundle\Entity\User1' => [
'class' => 'Symfony\Component\Security\Core\Encoder\PlaintextPasswordEncoder',
'arguments' => [false],
],
'JMS\FooBundle\Entity\User2' => [
'algorithm' => 'sha1',
'encode_as_base64' => false,
'iterations' => 5,
'hash_algorithm' => 'sha512',
'key_length' => 40,
'ignore_case' => false,
'cost' => 13,
'memory_cost' => null,
'time_cost' => null,
'threads' => null,
],
'JMS\FooBundle\Entity\User3' => [
'algorithm' => 'md5',
'hash_algorithm' => 'sha512',
'key_length' => 40,
'ignore_case' => false,
'encode_as_base64' => true,
'iterations' => 5000,
'cost' => 13,
'memory_cost' => null,
'time_cost' => null,
'threads' => null,
],
'JMS\FooBundle\Entity\User4' => new Reference('security.encoder.foo'),
'JMS\FooBundle\Entity\User5' => [
'class' => 'Symfony\Component\Security\Core\Encoder\Pbkdf2PasswordEncoder',
'arguments' => ['sha1', false, 5, 30],
],
'JMS\FooBundle\Entity\User6' => [
'class' => 'Symfony\Component\Security\Core\Encoder\BCryptPasswordEncoder',
'arguments' => [15],
],
'JMS\FooBundle\Entity\User7' => [
'class' => 'Symfony\Component\Security\Core\Encoder\Argon2idPasswordEncoder',
'arguments' => [256, 1, 2],
],
]], $container->getDefinition('security.encoder_factory.generic')->getArguments());
}
public function testRememberMeThrowExceptionsDefault()
{
$container = $this->getContainer('container1');

View File

@ -0,0 +1,14 @@
<?php
$this->load('container1.php', $container);
$container->loadFromExtension('security', [
'encoders' => [
'JMS\FooBundle\Entity\User7' => [
'algorithm' => 'argon2id',
'memory_cost' => 256,
'time_cost' => 1,
'threads' => 2,
],
],
]);

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<container xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:sec="http://symfony.com/schema/dic/security"
xsi:schemaLocation="http://symfony.com/schema/dic/services https://symfony.com/schema/dic/services/services-1.0.xsd">
<imports>
<import resource="container1.xml"/>
</imports>
<sec:config>
<sec:encoder class="JMS\FooBundle\Entity\User7" algorithm="argon2id" memory_cost="256" time_cost="1" threads="2" />
</sec:config>
</container>

View File

@ -0,0 +1,10 @@
imports:
- { resource: container1.yml }
security:
encoders:
JMS\FooBundle\Entity\User7:
algorithm: argon2id
memory_cost: 256
time_cost: 1
threads: 2

View File

@ -15,6 +15,7 @@ use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\SecurityBundle\Command\UserPasswordEncoderCommand;
use Symfony\Component\Console\Application as ConsoleApplication;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\Security\Core\Encoder\Argon2idPasswordEncoder;
use Symfony\Component\Security\Core\Encoder\Argon2iPasswordEncoder;
use Symfony\Component\Security\Core\Encoder\BCryptPasswordEncoder;
use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface;
@ -72,7 +73,7 @@ class UserPasswordEncoderCommandTest extends WebTestCase
public function testEncodePasswordArgon2i()
{
if (!Argon2iPasswordEncoder::isSupported()) {
if (!Argon2iPasswordEncoder::isSupported() || \defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13')) {
$this->markTestSkipped('Argon2i algorithm not available.');
}
$this->setupArgon2i();
@ -85,6 +86,27 @@ class UserPasswordEncoderCommandTest extends WebTestCase
$output = $this->passwordEncoderCommandTester->getDisplay();
$this->assertContains('Password encoding succeeded', $output);
$encoder = new Argon2iPasswordEncoder();
preg_match('# Encoded password\s+(\$argon2i?\$[\w,=\$+\/]+={0,2})\s+#', $output, $matches);
$hash = $matches[1];
$this->assertTrue($encoder->isPasswordValid($hash, 'password', null));
}
public function testEncodePasswordArgon2id()
{
if (!Argon2idPasswordEncoder::isSupported()) {
$this->markTestSkipped('Argon2i algorithm not available.');
}
$this->setupArgon2id();
$this->passwordEncoderCommandTester->execute([
'command' => 'security:encode-password',
'password' => 'password',
'user-class' => 'Custom\Class\Argon2id\User',
], ['interactive' => false]);
$output = $this->passwordEncoderCommandTester->getDisplay();
$this->assertContains('Password encoding succeeded', $output);
$encoder = new Argon2iPasswordEncoder();
preg_match('# Encoded password\s+(\$argon2id?\$[\w,=\$+\/]+={0,2})\s+#', $output, $matches);
$hash = $matches[1];
@ -153,8 +175,8 @@ class UserPasswordEncoderCommandTest extends WebTestCase
public function testEncodePasswordArgon2iOutput()
{
if (!Argon2iPasswordEncoder::isSupported()) {
$this->markTestSkipped('Argon2i algorithm not available.');
if (!Argon2iPasswordEncoder::isSupported() || \defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13')) {
$this->markTestSkipped('Argon2id algorithm not available.');
}
$this->setupArgon2i();
@ -167,6 +189,22 @@ class UserPasswordEncoderCommandTest extends WebTestCase
$this->assertNotContains(' Generated salt ', $this->passwordEncoderCommandTester->getDisplay());
}
public function testEncodePasswordArgon2idOutput()
{
if (!Argon2idPasswordEncoder::isSupported()) {
$this->markTestSkipped('Argon2id algorithm not available.');
}
$this->setupArgon2id();
$this->passwordEncoderCommandTester->execute([
'command' => 'security:encode-password',
'password' => 'p@ssw0rd',
'user-class' => 'Custom\Class\Argon2id\User',
], ['interactive' => false]);
$this->assertNotContains(' Generated salt ', $this->passwordEncoderCommandTester->getDisplay());
}
public function testEncodePasswordNoConfigForGivenUserClass()
{
if (method_exists($this, 'expectException')) {
@ -259,4 +297,17 @@ EOTXT
$this->passwordEncoderCommandTester = new CommandTester($passwordEncoderCommand);
}
private function setupArgon2id()
{
putenv('COLUMNS='.(119 + \strlen(PHP_EOL)));
$kernel = $this->createKernel(['test_case' => 'PasswordEncode', 'root_config' => 'argon2id.yml']);
$kernel->boot();
$application = new Application($kernel);
$passwordEncoderCommand = $application->get('security:encode-password');
$this->passwordEncoderCommandTester = new CommandTester($passwordEncoderCommand);
}
}

View File

@ -0,0 +1,7 @@
imports:
- { resource: config.yml }
security:
encoders:
Custom\Class\Argon2id\User:
algorithm: argon2id

View File

@ -19,6 +19,9 @@ CHANGELOG
* Dispatch `AuthenticationFailureEvent` on `security.authentication.failure`
* Dispatch `InteractiveLoginEvent` on `security.interactive_login`
* Dispatch `SwitchUserEvent` on `security.switch_user`
* Added `Argon2idPasswordEncoder`
* Deprecated using `Argon2iPasswordEncoder` while only the `argon2id` algorithm
is supported, use `Argon2idPasswordEncoder` instead
4.2.0
-----

View File

@ -0,0 +1,52 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Security\Core\Encoder;
/**
* @internal
*
* @author Robin Chalas <robin.chalas@gmail.com>
*/
trait Argon2Trait
{
private $memoryCost;
private $timeCost;
private $threads;
public function __construct(int $memoryCost = null, int $timeCost = null, int $threads = null)
{
$this->memoryCost = $memoryCost;
$this->timeCost = $timeCost;
$this->threads = $threads;
}
private function encodePasswordNative(string $raw, int $algorithm)
{
return password_hash($raw, $algorithm, [
'memory_cost' => $this->memoryCost ?? \PASSWORD_ARGON2_DEFAULT_MEMORY_COST,
'time_cost' => $this->timeCost ?? \PASSWORD_ARGON2_DEFAULT_TIME_COST,
'threads' => $this->threads ?? \PASSWORD_ARGON2_DEFAULT_THREADS,
]);
}
private function encodePasswordSodiumFunction(string $raw)
{
$hash = \sodium_crypto_pwhash_str(
$raw,
\SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE,
\SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE
);
\sodium_memzero($raw);
return $hash;
}
}

View File

@ -21,25 +21,7 @@ use Symfony\Component\Security\Core\Exception\BadCredentialsException;
*/
class Argon2iPasswordEncoder extends BasePasswordEncoder implements SelfSaltingEncoderInterface
{
private $config = [];
/**
* Argon2iPasswordEncoder constructor.
*
* @param int|null $memoryCost memory usage of the algorithm
* @param int|null $timeCost number of iterations
* @param int|null $threads number of parallel threads
*/
public function __construct(int $memoryCost = null, int $timeCost = null, int $threads = null)
{
if (\defined('PASSWORD_ARGON2I')) {
$this->config = [
'memory_cost' => $memoryCost ?? \PASSWORD_ARGON2_DEFAULT_MEMORY_COST,
'time_cost' => $timeCost ?? \PASSWORD_ARGON2_DEFAULT_TIME_COST,
'threads' => $threads ?? \PASSWORD_ARGON2_DEFAULT_THREADS,
];
}
}
use Argon2Trait;
public static function isSupported()
{
@ -64,10 +46,13 @@ class Argon2iPasswordEncoder extends BasePasswordEncoder implements SelfSaltingE
}
if (\PHP_VERSION_ID >= 70200 && \defined('PASSWORD_ARGON2I')) {
return $this->encodePasswordNative($raw);
}
if (\function_exists('sodium_crypto_pwhash_str')) {
return $this->encodePasswordSodiumFunction($raw);
return $this->encodePasswordNative($raw, \PASSWORD_ARGON2I);
} elseif (\function_exists('sodium_crypto_pwhash_str')) {
if (0 === strpos($hash = $this->encodePasswordSodiumFunction($raw), Argon2idPasswordEncoder::HASH_PREFIX)) {
@trigger_error(sprintf('Using "%s" while only the "argon2id" algorithm is supported is deprecated since Symfony 4.3, use "%s" instead.', __CLASS__, Argon2idPasswordEncoder::class), E_USER_DEPRECATED);
}
return $hash;
}
if (\extension_loaded('libsodium')) {
return $this->encodePasswordSodiumExtension($raw);
@ -81,10 +66,20 @@ class Argon2iPasswordEncoder extends BasePasswordEncoder implements SelfSaltingE
*/
public function isPasswordValid($encoded, $raw, $salt)
{
// If $encoded was created via "sodium_crypto_pwhash_str()", the hashing algorithm may be "argon2id" instead of "argon2i".
// In this case, "password_verify()" cannot be used.
if (\PHP_VERSION_ID >= 70200 && \defined('PASSWORD_ARGON2I') && (false === strpos($encoded, '$argon2id$'))) {
return !$this->isPasswordTooLong($raw) && password_verify($raw, $encoded);
if ($this->isPasswordTooLong($raw)) {
return false;
}
if (\PHP_VERSION_ID >= 70200 && \defined('PASSWORD_ARGON2I')) {
// If $encoded was created via "sodium_crypto_pwhash_str()", the hashing algorithm may be "argon2id" instead of "argon2i"
if ($isArgon2id = (0 === strpos($encoded, Argon2idPasswordEncoder::HASH_PREFIX))) {
@trigger_error(sprintf('Calling "%s()" with a password hashed using argon2id is deprecated since Symfony 4.3, use "%s" instead.', __METHOD__, Argon2idPasswordEncoder::class), E_USER_DEPRECATED);
}
// Remove the right part of the OR in 5.0
if (\defined('PASSWORD_ARGON2I') || $isArgon2id && \defined('PASSWORD_ARGON2ID')) {
return password_verify($raw, $encoded);
}
}
if (\function_exists('sodium_crypto_pwhash_str_verify')) {
$valid = !$this->isPasswordTooLong($raw) && \sodium_crypto_pwhash_str_verify($encoded, $raw);
@ -102,23 +97,6 @@ class Argon2iPasswordEncoder extends BasePasswordEncoder implements SelfSaltingE
throw new \LogicException('Argon2i algorithm is not supported. Please install the libsodium extension or upgrade to PHP 7.2+.');
}
private function encodePasswordNative($raw)
{
return password_hash($raw, \PASSWORD_ARGON2I, $this->config);
}
private function encodePasswordSodiumFunction($raw)
{
$hash = \sodium_crypto_pwhash_str(
$raw,
\SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE,
\SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE
);
\sodium_memzero($raw);
return $hash;
}
private function encodePasswordSodiumExtension($raw)
{
$hash = \Sodium\crypto_pwhash_str(

View File

@ -0,0 +1,85 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Security\Core\Encoder;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\Exception\LogicException;
/**
* Hashes passwords using the Argon2id algorithm.
*
* @author Robin Chalas <robin.chalas@gmail.com>
*
* @final
*/
class Argon2idPasswordEncoder extends BasePasswordEncoder implements SelfSaltingEncoderInterface
{
use Argon2Trait;
/**
* @internal
*/
public const HASH_PREFIX = '$argon2id';
public static function isSupported()
{
return \defined('PASSWORD_ARGON2ID') || \defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13');
}
/**
* {@inheritdoc}
*/
public function encodePassword($raw, $salt)
{
if ($this->isPasswordTooLong($raw)) {
throw new BadCredentialsException('Invalid password.');
}
if (\defined('PASSWORD_ARGON2ID')) {
return $this->encodePasswordNative($raw, \PASSWORD_ARGON2ID);
}
if (\defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13')) {
$hash = \sodium_crypto_pwhash_str(
$raw,
\SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE,
\SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE
);
\sodium_memzero($raw);
return $hash;
}
throw new LogicException('Algorithm "argon2id" is not supported. Please install the libsodium extension or upgrade to PHP 7.3+.');
}
/**
* {@inheritdoc}
*/
public function isPasswordValid($encoded, $raw, $salt)
{
if (0 !== strpos($encoded, self::HASH_PREFIX)) {
return false;
}
if (\defined('PASSWORD_ARGON2ID')) {
return !$this->isPasswordTooLong($raw) && password_verify($raw, $encoded);
}
if (\function_exists('sodium_crypto_pwhash_str_verify')) {
$valid = !$this->isPasswordTooLong($raw) && \sodium_crypto_pwhash_str_verify($encoded, $raw);
\sodium_memzero($raw);
return $valid;
}
throw new LogicException('Algorithm "argon2id" is not supported. Please install the libsodium extension or upgrade to PHP 7.3+.');
}
}

View File

@ -117,6 +117,15 @@ class EncoderFactory implements EncoderFactoryInterface
$config['threads'],
],
];
case 'argon2id':
return [
'class' => Argon2idPasswordEncoder::class,
'arguments' => [
$config['memory_cost'],
$config['time_cost'],
$config['threads'],
],
];
}
return [

View File

@ -23,7 +23,7 @@ class Argon2iPasswordEncoderTest extends TestCase
protected function setUp()
{
if (!Argon2iPasswordEncoder::isSupported()) {
if (!Argon2iPasswordEncoder::isSupported() || \defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13')) {
$this->markTestSkipped('Argon2i algorithm is not supported.');
}
}

View File

@ -0,0 +1,65 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Security\Core\Tests\Encoder;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Encoder\Argon2idPasswordEncoder;
class Argon2idPasswordEncoderTest extends TestCase
{
protected function setUp()
{
if (!Argon2idPasswordEncoder::isSupported()) {
$this->markTestSkipped('Argon2i algorithm is not supported.');
}
}
public function testValidationWithConfig()
{
$encoder = new Argon2idPasswordEncoder(8, 4, 1);
$result = $encoder->encodePassword('password', null);
$this->assertTrue($encoder->isPasswordValid($result, 'password', null));
$this->assertFalse($encoder->isPasswordValid($result, 'anotherPassword', null));
}
public function testValidation()
{
$encoder = new Argon2idPasswordEncoder();
$result = $encoder->encodePassword('password', null);
$this->assertTrue($encoder->isPasswordValid($result, 'password', null));
$this->assertFalse($encoder->isPasswordValid($result, 'anotherPassword', null));
}
/**
* @expectedException \Symfony\Component\Security\Core\Exception\BadCredentialsException
*/
public function testEncodePasswordLength()
{
$encoder = new Argon2idPasswordEncoder();
$encoder->encodePassword(str_repeat('a', 4097), 'salt');
}
public function testCheckPasswordLength()
{
$encoder = new Argon2idPasswordEncoder();
$result = $encoder->encodePassword(str_repeat('a', 4096), null);
$this->assertFalse($encoder->isPasswordValid($result, str_repeat('a', 4097), null));
$this->assertTrue($encoder->isPasswordValid($result, str_repeat('a', 4096), null));
}
public function testUserProvidedSaltIsNotUsed()
{
$encoder = new Argon2idPasswordEncoder();
$result = $encoder->encodePassword('password', 'salt');
$this->assertTrue($encoder->isPasswordValid($result, 'password', 'anotherSalt'));
}
}