feature #21604 [Security] Argon2i Password Encoder (zanbaldwin)

This PR was merged into the 3.4 branch.

Discussion
----------

[Security] Argon2i Password Encoder

| Q             | A
| ------------- | ---
| Branch?       | master
| Bug fix?      | no
| New feature?  | yes
| BC breaks?    | no
| Deprecations? | no
| Tests pass?   | yes
| Fixed tickets |
| License       | MIT
| Doc PR        | WIP

Since the [libsodium RFC](https://wiki.php.net/rfc/libsodium) passed with flying colours, I'd like to kick start a discussion about adding Argon2i as a password encoder to the security component. The initial code proposal in this PR supports both the upcoming public API confirmed for PHP 7.2, and the [libsodium PECL extension](https://pecl.php.net/package/libsodium) for those below 7.2 (available for PHP 5.4+).

#### Concerns

- Should the test cover hash length? At the moment the result of Argon2i is 96 characters, but because the hashing parameters are included in the result (`$argon2i$v=19$m=32768,t=4,p=1$...`) this is not guaranteed.
- I've used one password encoder class because the result *should* be the same whether running natively in 7.2 or from the PECL extension, but should the logic be split out into separate private methods (like `Argon2iPasswordEncoder::encodePassword()`) or not (like in `Argon2iPasswordEncoder::isPasswordValid()`)? Since I can't really find anything concrete on Symfony choosing one way over another I'm assuming it's down to personal preference?

#### The Future

Whilst the libsodium RFC has been approved and the public API confirmed, there has been no confirmation of Argon2i becoming an official algorithm for `passhword_hash()`. If that is confirmed, then the implementation should *absolutely* use the native `password_*` functions since the `sodium_*` functions do not have an equivalent to the `password_needs_rehash()` function.

Any feedback would be greatly appreciated 😃

Commits
-------

be093dd79a Argon2i Password Encoder
This commit is contained in:
Fabien Potencier 2017-09-29 07:04:03 -07:00
commit 1b300985ff
12 changed files with 308 additions and 0 deletions

View File

@ -16,6 +16,7 @@ CHANGELOG
* deprecated HTTP digest authentication
* deprecated command `acl:set` along with `SetAclCommand` class
* deprecated command `init:acl` along with `InitAclCommand` class
* Added support for the new Argon2i password encoder
3.3.0
-----

View File

@ -29,6 +29,7 @@ use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\Security\Core\Authorization\ExpressionLanguage;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
use Symfony\Component\Security\Core\Encoder\Argon2iPasswordEncoder;
/**
* SecurityExtension.
@ -607,6 +608,18 @@ class SecurityExtension extends Extension
);
}
// Argon2i encoder
if ('argon2i' === $config['algorithm']) {
if (!Argon2iPasswordEncoder::isSupported()) {
throw new InvalidConfigurationException('Argon2i algorithm is not supported. Please install the libsodium extension or upgrade to PHP 7.2+.');
}
return array(
'class' => 'Symfony\Component\Security\Core\Encoder\Argon2iPasswordEncoder',
'arguments' => array(),
);
}
// run-time configured encoder
return $config;
}

View File

@ -19,6 +19,7 @@ use Symfony\Bundle\SecurityBundle\SecurityBundle;
use Symfony\Bundle\SecurityBundle\DependencyInjection\SecurityExtension;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\Security\Core\Authorization\AccessDecisionManager;
use Symfony\Component\Security\Core\Encoder\Argon2iPasswordEncoder;
abstract class CompleteConfigurationTest extends TestCase
{
@ -451,6 +452,18 @@ abstract class CompleteConfigurationTest extends TestCase
)), $container->getDefinition('security.encoder_factory.generic')->getArguments());
}
public function testArgon2iEncoder()
{
if (!Argon2iPasswordEncoder::isSupported()) {
$this->markTestSkipped('Argon2i algorithm is not supported.');
}
$this->assertSame(array(array('JMS\FooBundle\Entity\User7' => array(
'class' => 'Symfony\Component\Security\Core\Encoder\Argon2iPasswordEncoder',
'arguments' => array(),
))), $this->getContainer('argon2i_encoder')->getDefinition('security.encoder_factory.generic')->getArguments());
}
/**
* @group legacy
* @expectedDeprecation The "security.acl" configuration key is deprecated since version 3.4 and will be removed in 4.0. Install symfony/acl-bundle and use the "acl" key instead.

View File

@ -0,0 +1,19 @@
<?php
$container->loadFromExtension('security', array(
'encoders' => array(
'JMS\FooBundle\Entity\User7' => array(
'algorithm' => 'argon2i',
),
),
'providers' => array(
'default' => array('id' => 'foo'),
),
'firewalls' => array(
'main' => array(
'form_login' => false,
'http_basic' => null,
'logout_on_user_change' => true,
),
),
));

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<srv:container xmlns="http://symfony.com/schema/dic/security"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:srv="http://symfony.com/schema/dic/services"
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
<config>
<encoder class="JMS\FooBundle\Entity\User7" algorithm="argon2i" />
<provider name="default" id="foo" />
<firewall name="main" logout-on-user-change="true">
<form-login login-path="/login" />
</firewall>
</config>
</srv:container>

View File

@ -0,0 +1,13 @@
security:
encoders:
JMS\FooBundle\Entity\User6:
algorithm: argon2i
providers:
default: { id: foo }
firewalls:
main:
form_login: false
http_basic: ~
logout_on_user_change: true

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\Argon2iPasswordEncoder;
use Symfony\Component\Security\Core\Encoder\BCryptPasswordEncoder;
use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface;
use Symfony\Component\Security\Core\Encoder\Pbkdf2PasswordEncoder;
@ -69,6 +70,27 @@ class UserPasswordEncoderCommandTest extends WebTestCase
$this->assertTrue($encoder->isPasswordValid($hash, 'password', null));
}
public function testEncodePasswordArgon2i()
{
if (!Argon2iPasswordEncoder::isSupported()) {
$this->markTestSkipped('Argon2i algorithm not available.');
}
$this->setupArgon2i();
$this->passwordEncoderCommandTester->execute(array(
'command' => 'security:encode-password',
'password' => 'password',
'user-class' => 'Custom\Class\Argon2i\User',
), array('interactive' => false));
$output = $this->passwordEncoderCommandTester->getDisplay();
$this->assertContains('Password encoding succeeded', $output);
$encoder = new Argon2iPasswordEncoder();
preg_match('# Encoded password\s+(\$argon2i\$[\w\d,=\$+\/]+={0,2})\s+#', $output, $matches);
$hash = $matches[1];
$this->assertTrue($encoder->isPasswordValid($hash, 'password', null));
}
public function testEncodePasswordPbkdf2()
{
$this->passwordEncoderCommandTester->execute(array(
@ -129,6 +151,22 @@ class UserPasswordEncoderCommandTest extends WebTestCase
$this->assertNotContains(' Generated salt ', $this->passwordEncoderCommandTester->getDisplay());
}
public function testEncodePasswordArgon2iOutput()
{
if (!Argon2iPasswordEncoder::isSupported()) {
$this->markTestSkipped('Argon2i algorithm not available.');
}
$this->setupArgon2i();
$this->passwordEncoderCommandTester->execute(array(
'command' => 'security:encode-password',
'password' => 'p@ssw0rd',
'user-class' => 'Custom\Class\Argon2i\User',
), array('interactive' => false));
$this->assertNotContains(' Generated salt ', $this->passwordEncoderCommandTester->getDisplay());
}
public function testEncodePasswordNoConfigForGivenUserClass()
{
if (method_exists($this, 'expectException')) {
@ -230,4 +268,17 @@ EOTXT
{
$this->passwordEncoderCommandTester = null;
}
private function setupArgon2i()
{
putenv('COLUMNS='.(119 + strlen(PHP_EOL)));
$kernel = $this->createKernel(array('test_case' => 'PasswordEncode', 'root_config' => 'argon2i'));
$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\Argon2i\User:
algorithm: argon2i

View File

@ -14,6 +14,7 @@ CHANGELOG
the user will always be logged out when the user has changed between
requests.
* deprecated HTTP digest authentication
* Added a new password encoder for the Argon2i hashing algorithm
3.3.0
-----

View File

@ -0,0 +1,104 @@
<?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;
/**
* Argon2iPasswordEncoder uses the Argon2i hashing algorithm.
*
* @author Zan Baldwin <hello@zanbaldwin.com>
*/
class Argon2iPasswordEncoder extends BasePasswordEncoder implements SelfSaltingEncoderInterface
{
public static function isSupported()
{
return (\PHP_VERSION_ID >= 70200 && \defined('PASSWORD_ARGON2I'))
|| \function_exists('sodium_crypto_pwhash_str')
|| \extension_loaded('libsodium');
}
/**
* {@inheritdoc}
*/
public function encodePassword($raw, $salt)
{
if ($this->isPasswordTooLong($raw)) {
throw new BadCredentialsException('Invalid password.');
}
if (\PHP_VERSION_ID >= 70200 && \defined('PASSWORD_ARGON2I')) {
return $this->encodePasswordNative($raw);
}
if (\function_exists('sodium_crypto_pwhash_str')) {
return $this->encodePasswordSodiumFunction($raw);
}
if (\extension_loaded('libsodium')) {
return $this->encodePasswordSodiumExtension($raw);
}
throw new \LogicException('Argon2i algorithm is not supported. Please install the libsodium extension or upgrade to PHP 7.2+.');
}
/**
* {@inheritdoc}
*/
public function isPasswordValid($encoded, $raw, $salt)
{
if (\PHP_VERSION_ID >= 70200 && \defined('PASSWORD_ARGON2I')) {
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;
}
if (\extension_loaded('libsodium')) {
$valid = !$this->isPasswordTooLong($raw) && \Sodium\crypto_pwhash_str_verify($encoded, $raw);
\Sodium\memzero($raw);
return $valid;
}
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);
}
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(
$raw,
\Sodium\CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE,
\Sodium\CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE
);
\Sodium\memzero($raw);
return $hash;
}
}

View File

@ -109,6 +109,12 @@ class EncoderFactory implements EncoderFactoryInterface
'class' => BCryptPasswordEncoder::class,
'arguments' => array($config['cost']),
);
case 'argon2i':
return array(
'class' => Argon2iPasswordEncoder::class,
'arguments' => array(),
);
}
return array(

View File

@ -0,0 +1,62 @@
<?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\Argon2iPasswordEncoder;
/**
* @author Zan Baldwin <hello@zanbaldwin.com>
*/
class Argon2iPasswordEncoderTest extends TestCase
{
const PASSWORD = 'password';
protected function setUp()
{
if (!Argon2iPasswordEncoder::isSupported()) {
$this->markTestSkipped('Argon2i algorithm is not supported.');
}
}
public function testValidation()
{
$encoder = new Argon2iPasswordEncoder();
$result = $encoder->encodePassword(self::PASSWORD, null);
$this->assertTrue($encoder->isPasswordValid($result, self::PASSWORD, null));
$this->assertFalse($encoder->isPasswordValid($result, 'anotherPassword', null));
}
/**
* @expectedException \Symfony\Component\Security\Core\Exception\BadCredentialsException
*/
public function testEncodePasswordLength()
{
$encoder = new Argon2iPasswordEncoder();
$encoder->encodePassword(str_repeat('a', 4097), 'salt');
}
public function testCheckPasswordLength()
{
$encoder = new Argon2iPasswordEncoder();
$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 Argon2iPasswordEncoder();
$result = $encoder->encodePassword(self::PASSWORD, 'salt');
$this->assertTrue($encoder->isPasswordValid($result, self::PASSWORD, 'anotherSalt'));
}
}