8000 feature #21604 [Security] Argon2i Password Encoder (zanbaldwin) · symfony/symfony@1b30098 · GitHub
[go: up one dir, main page]

Skip to content

Commit 1b30098

Browse files
committed
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 ------- be093dd Argon2i Password Encoder
2 parents d2d9274 + be093dd commit 1b30098

File tree

12 files changed

+308
-0
lines changed

12 files changed

+308
-0
lines changed

src/Symfony/Bundle/SecurityBundle/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ CHANGELOG
1616
* deprecated HTTP digest authentication
1717
* deprecated command `acl:set` along with `SetAclCommand` class
1818
* deprecated command `init:acl` along with `InitAclCommand` class
19+
* Added support for the new Argon2i password encoder
1920

2021
3.3.0
2122
-----

src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
use Symfony\Component\Config\FileLocator;
3030
use Symfony\Component\Security\Core\Authorization\ExpressionLanguage;
3131
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
32+
use Symfony\Component\Security\Core\Encoder\Argon2iPasswordEncoder;
3233

3334
/**
3435
* SecurityExtension.
@@ -607,6 +608,18 @@ private function createEncoder($config, ContainerBuilder $container)
607608
);
608609
}
609610

611+
// Argon2i encoder
612+
if ('argon2i' === $config['algorithm']) {
613+
if (!Argon2iPasswordEncoder::isSupported()) {
614+
throw new InvalidConfigurationException('Argon2i algorithm is not supported. Please install the libsodium extension or upgrade to PHP 7.2+.');
615+
}
616+
617+
return array(
618+
'class' => 'Symfony\Component\Security\Core\Encoder\Argon2iPasswordEncoder',
619+
'arguments' => array(),
620+
);
621+
}
622+
610623
// run-time configured encoder
611624
return $config;
612625
}

src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/CompleteConfigurationTest.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use Symfony\Bundle\SecurityBundle\DependencyInjection\SecurityExtension;
2020
use Symfony\Component\DependencyInjection\ContainerBuilder;
2121
use Symfony\Component\Security\Core\Authorization\AccessDecisionManager;
22+
use Symfony\Component\Security\Core\Encoder\Argon2iPasswordEncoder;
2223

2324
abstract class CompleteConfigurationTest extends TestCase
2425
{
@@ -451,6 +452,18 @@ public function testEncoders()
451452
)), $container->getDefinition('security.encoder_factory.generic')->getArguments());
452453
}
453454

455+
public function testArgon2iEncoder()
456+
{
457+
if (!Argon2iPasswordEncoder::isSupported()) {
458+
$this->markTestSkipped('Argon2i algorithm is not supported.');
459+
}
460+
461+
$this->assertSame(array(array('JMS\FooBundle\Entity\User7' => array(
462+
'class' => 'Symfony\Component\Security\Core\Encoder\Argon2iPasswordEncoder',
463+
'arguments' => array(),
464+
))), $this->getContainer('argon2i_encoder')->getDefinition('security.encoder_factory.generic')->getArguments());
465+
}
466+
454467
/**
455468
* @group legacy
456469
* @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.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
$container->loadFromExtension('security', array(
4+
'encoders' => array(
5+
'JMS\FooBundle\Entity\User7' => array(
6+
'algorithm' => 'argon2i',
7+
),
8+
),
9+
'providers' => array(
10+
'default' => array('id' => 'foo'),
11+
),
12+
'firewalls' => array(
13+
'main' => array(
14+
'form_login' => false,
15+
'http_basic' => null,
16+
'logout_on_user_change' => true,
17+
),
18+
),
19+
));
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
3+
<srv:container xmlns="http://symfony.com/schema/dic/security"
4+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
5+
xmlns:srv="http://symfony.com/schema/dic/services"
6+
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
7+
8+
<config>
9+
<encoder class="JMS\FooBundle\Entity\User7" algorithm="argon2i" />
10+
11+
<provider name="default" id="foo" />
12+
13+
<firewall name="main" logout-on-user-change="true">
14+
<form-login login-path="/login" />
15+
</firewall>
16+
</config>
17+
18+
</srv:container>
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
security:
2+
encoders:
3+
JMS\FooBundle\Entity\User6:
4+
algorithm: argon2i
5+
6+
providers:
7+
default: { id: foo }
8+
9+
firewalls:
10+
main:
11+
form_login: false
12+
http_basic: ~
13+
logout_on_user_change: true

src/Symfony/Bundle/SecurityBundle/Tests/Functional/UserPasswordEncoderCommandTest.php

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use Symfony\Bundle\SecurityBundle\Command\UserPasswordEncoderCommand;
1616
use Symfony\Component\Console\Application as ConsoleApplication;
1717
use Symfony\Component\Console\Tester\CommandTester;
18+
use Symfony\Component\Security\Core\Encoder\Argon2iPasswordEncoder;
1819
use Symfony\Component\Security\Core\Encoder\BCryptPasswordEncoder;
1920
use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface;
2021
use Symfony\Component\Security\Core\Encoder\Pbkdf2PasswordEncoder;
@@ -69,6 +70,27 @@ public function testEncodePasswordBcrypt()
6970
$this->assertTrue($encoder->isPasswordValid($hash, 'password', null));
7071
}
7172

73+
public function testEncodePasswordArgon2i()
74+
{
75+
if (!Argon2iPasswordEncoder::isSupported()) {
76+
$this->markTestSkipped('Argon2i algorithm not available.');
77+
}
78+
$this->setupArgon2i();
79+
$this->passwordEncoderCommandTester->execute(array(
80+
'command' => 'security:encode-password',
81+
'password' => 'password',
82+
'user-class' => 'Custom\Class\Argon2i\User',
83+
), array('interactive' => false));
84+
85+
$output = $this->passwordEncoderCommandTester->getDisplay();
86+
$this->assertContains('Password encoding succeeded', $output);
87+
88+
$encoder = new Argon2iPasswordEncoder();
89+
preg_match('# Encoded password\s+(\$argon2i\$[\w\d,=\$+\/]+={0,2})\s+#', $output, $matches);
90+
$hash = $matches[1];
91+
$this->assertTrue($encoder->isPasswordValid($hash, 'password', null));
92+
}
93+
7294
public function testEncodePasswordPbkdf2()
7395
{
7496
$this->passwordEncoderCommandTester->execute(array(
@@ -129,6 +151,22 @@ public function testEncodePasswordBcryptOutput()
129151
$this->assertNotContains(' Generated salt ', $this->passwordEncoderCommandTester->getDisplay());
130152
}
131153

154+
public function testEncodePasswordArgon2iOutput()
155+
{
156+
if (!Argon2iPasswordEncoder::isSupported()) {
157+
$this->markTestSkipped('Argon2i algorithm not available.');
158+
}
159+
160+
$this->setupArgon2i();
161+
$this->passwordEncoderCommandTester->execute(array(
162+
'command' => 'security:encode-password',
163+
'password' => 'p@ssw0rd',
164+
'user-class' => 'Custom\Class\Argon2i\User',
165+
), array('interactive' => false));
166+
167+
$this->assertNotContains(' Generated salt ', $this->passwordEncoderCommandTester->getDisplay());
168+
}
169+
132170
public function testEncodePasswordNoConfigForGivenUserClass()
133171
{
134172
if (method_exists($this, 'expectException')) {
@@ -230,4 +268,17 @@ protected function tearDown()
230268
{
231269
$this->passwordEncoderCommandTester = null;
232270
}
271+
272+
private function setupArgon2i()
273+
{
274+
putenv('COLUMNS='.(119 + strlen(PHP_EOL)));
275+
$kernel = $this->createKernel(array('test_case' => 'PasswordEncode', 'root_config' => 'argon2i'));
276+
$kernel->boot();
277+
278+
$application = new Application($kernel);
279+
280+
$passwordEncoderCommand = $application->get('security:encode-password');
281+
282+
$this->passwordEncoderCommandTester = new CommandTester($passwordEncoderCommand);
283+
}
233284
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
imports:
2+
- { resource: config.yml }
3+
4+
security:
5+
encoders:
6+
Custom\Class\Argon2i\User:
7+
algorithm: argon2i

src/Symfony/Component/Security/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ CHANGELOG
1414
the user will always be logged out when the user has changed between
1515
requests.
1616
* deprecated HTTP digest authentication
17+
* Added a new password encoder for the Argon2i hashing algorithm
1718

1819
3.3.0
1920
-----
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Security\Core\Encoder;
13+
14+
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
15+
16+
/**
17+
* Argon2iPasswordEncoder uses the Argon2i hashing algorithm.
18+
*
19+
* @author Zan Baldwin <hello@zanbaldwin.com>
20+
*/
21+
class Argon2iPasswordEncoder extends BasePasswordEncoder implements SelfSaltingEncoderInterface
22+
{
23+
public static function isSupported()
24+
{
25+
return (\PHP_VERSION_ID >= 70200 && \defined('PASSWORD_ARGON2I'))
26+
|| \function_exists('sodium_crypto_pwhash_str')
27+
|| \extension_loaded('libsodium');
28+
}
29+
30+
/**
31+
* {@inheritdoc}
32+
*/
33+
public function encodePassword($raw, $salt)
34+
{
35+
if ($this->isPasswordTooLong($raw)) {
36+
throw new BadCredentialsException('Invalid password.');
37+
}
38+
39+
if (\PHP_VERSION_ID >= 70200 && \defined('PASSWORD_ARGON2I')) {
40+
return $this->encodePasswordNative($raw);
41+
}
42+
if (\function_exists('sodium_crypto_pwhash_str')) {
43+
return $this->encodePasswordSodiumFunction($raw);
44+
}
45+
if (\extension_loaded('libsodium')) {
46+
return $this->encodePasswordSodiumExtension($raw);
47+
}
48+
49+
throw new \LogicException('Argon2i algorithm is not supported. Please install the libsodium extension or upgrade to PHP 7.2+.');
50+
}
51+
52+
/**
53+
* {@inheritdoc}
54+
*/
55+
public function isPasswordValid($encoded, $raw, $salt)
56+
{
57+
if (\PHP_VERSION_ID >= 70200 && \defined('PASSWORD_ARGON2I')) {
58+
return !$this->isPasswordTooLong($raw) && password_verify($raw, $encoded);
59+
}
60+
if (\function_exists('sodium_crypto_pwhash_str_verify')) {
61+
$valid = !$this->isPasswordTooLong($raw) && \sodium_crypto_pwhash_str_verify($encoded, $raw);
62+
\sodium_memzero($raw);
63+
64+
return $valid;
65+
}
66+
if (\extension_loaded('libsodium')) {
67+
$valid = !$this->isPasswordTooLong($raw) && \Sodium\crypto_pwhash_str_verify($encoded, $raw);
68+
\Sodium\memzero($raw);
69+
70+
return $valid;
71+
}
72+
73+
throw new \LogicException('Argon2i algorithm is not supported. Please install the libsodium extension or upgrade to PHP 7.2+.');
74+
}
75+
76+
private function encodePasswordNative($raw)
77+
{
78+
return password_hash($raw, \PASSWORD_ARGON2I);
79+
}
80+
81+
private function encodePasswordSodiumFunction($raw)
82+
{
83+
$hash = \sodium_crypto_pwhash_str(
84+
$raw,
85+
\SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE,
86+
\SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE
87+
);
88+
\sodium_memzero($raw);
89+
90+
return $hash;
91+
}
92+
93+
private function encodePasswordSodiumExtension($raw)
94+
{
95+
$hash = \Sodium\crypto_pwhash_str(
96+
$raw,
97+
\Sodium\CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE,
98+
\Sodium\CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE
99+
);
100+
\Sodium\memzero($raw);
101+
102+
return $hash;
103+
}
104+
}

src/Symfony/Component/Security/Core/Encoder/EncoderFactory.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,12 @@ private function getEncoderConfigFromAlgorithm($config)
109109
'class' => BCryptPasswordEncoder::class,
110110
'arguments' => array($config['cost']),
111111
);
112+
113+
case 'argon2i':
114+
return array(
115+
'class' => Argon2iPasswordEncoder::class,
116+
'arguments' => array(),
117+
);
112118
}
113119

114120
return array(
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Security\Core\Tests\Encoder;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\Security\Core\Encoder\Argon2iPasswordEncoder;
16+
17+
/**
18+
* @author Zan Baldwin <hello@zanbaldwin.com>
19+
*/
20+
class Argon2iPasswordEncoderTest extends TestCase
21+
{
22+
const PASSWORD = 'password';
23+
24+
protected function setUp()
25+
{
26+
if (!Argon2iPasswordEncoder::isSupported()) {
27+
$this->markTestSkipped('Argon2i algorithm is not supported.');
28+
}
29+
}
30+
31+
public function testValidation()
32+
{
33+
$encoder = new Argon2iPasswordEncoder();
34+
$result = $encoder->encodePassword(self::PASSWORD, null);
35+
$this->assertTrue($encoder->isPasswordValid($result, self::PASSWORD, null));
36+
$this->assertFalse($encoder->isPasswordValid($result, 'anotherPassword', null));
37+
}
38+
39+
/**
40+
* @expectedException \Symfony\Component\Security\Core\Exception\BadCredentialsException
41+
*/
42+
public function testEncodePasswordLength()
43+
{
44+
$encoder = new Argon2iPasswordEncoder();
45+
$encoder->encodePassword(str_repeat('a', 4097), 'salt');
46+
}
47+
48+
public function testCheckPasswordLength()
49+
{
50+
$encoder = new Argon2iPasswordEncoder();
51+
$result = $encoder->encodePassword(str_repeat('a', 4096), null);
52+
$this->assertFalse($encoder->isPasswordValid($result, str_repeat('a', 4097), null));
53+
$this->assertTrue($encoder->isPasswordValid($result, str_repeat('a', 4096), null));
54+
}
55+
56+
public function testUserProvidedSaltIsNotUsed()
57+
{
58+
$encoder = new Argon2iPasswordEncoder();
59+
$result = $encoder->encodePassword(self::PASSWORD, 'salt');
60+
$this->assertTrue($encoder->isPasswordValid($result, self::PASSWORD, 'anotherSalt'));
61+
}
62+
}

0 commit comments

Comments
 (0)
0