Skip to content

Commit 11304b0

Browse files
committed
feature #30968 [Security] Add Argon2idPasswordEncoder (chalasr)
This PR was merged into the 4.3-dev branch. Discussion ---------- [Security] Add Argon2idPasswordEncoder | Q | A | ------------- | --- | Branch? | master | Bug fix? | no | New feature? | yes | BC breaks? | no | Deprecations? | yes | Tests pass? | yes | Fixed tickets | #28093 | License | MIT | Doc PR | TODO Currently we have a `Argon2iPasswordEncoder` that may hash passwords using `argon2id` instead of `argon2i` (platform-dependent) which is not good. This deprecates producing/validating `argon2id` hashed passwords using the `Argon2iPasswordEncoder`, and adds a `Argon2idPasswordEncoder` able to produce/validate `argon2id` hashed passwords only. #EUFOSSA Commits ------- 0c82173b24 [Security] Add Argon2idPasswordEncoder
2 parents f948fed + c3eebe4 commit 11304b0

File tree

6 files changed

+234
-45
lines changed

6 files changed

+234
-45
lines changed

Encoder/Argon2Trait.php

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
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+
/**
15+
* @internal
16+
*
17+
* @author Robin Chalas <[email protected]>
18+
*/
19+
trait Argon2Trait
20+
{
21+
private $memoryCost;
22+
private $timeCost;
23+
private $threads;
24+
25+
public function __construct(int $memoryCost = null, int $timeCost = null, int $threads = null)
26+
{
27+
$this->memoryCost = $memoryCost;
28+
$this->timeCost = $timeCost;
29+
$this->threads = $threads;
30+
}
31+
32+
private function encodePasswordNative(string $raw, int $algorithm)
33+
{
34+
return password_hash($raw, $algorithm, [
35+
'memory_cost' => $this->memoryCost ?? \PASSWORD_ARGON2_DEFAULT_MEMORY_COST,
36+
'time_cost' => $this->timeCost ?? \PASSWORD_ARGON2_DEFAULT_TIME_COST,
37+
'threads' => $this->threads ?? \PASSWORD_ARGON2_DEFAULT_THREADS,
38+
]);
39+
}
40+
41+
private function encodePasswordSodiumFunction(string $raw)
42+
{
43+
$hash = \sodium_crypto_pwhash_str(
44+
$raw,
45+
\SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE,
46+
\SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE
47+
);
48+
\sodium_memzero($raw);
49+
50+
return $hash;
51+
}
52+
}

Encoder/Argon2iPasswordEncoder.php

Lines changed: 22 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -21,25 +21,7 @@
2121
*/
2222
class Argon2iPasswordEncoder extends BasePasswordEncoder implements SelfSaltingEncoderInterface
2323
{
24-
private $config = [];
25-
26-
/**
27-
* Argon2iPasswordEncoder constructor.
28-
*
29-
* @param int|null $memoryCost memory usage of the algorithm
30-
* @param int|null $timeCost number of iterations
31-
* @param int|null $threads number of parallel threads
32-
*/
33-
public function __construct(int $memoryCost = null, int $timeCost = null, int $threads = null)
34-
{
35-
if (\defined('PASSWORD_ARGON2I')) {
36-
$this->config = [
37-
'memory_cost' => $memoryCost ?? \PASSWORD_ARGON2_DEFAULT_MEMORY_COST,
38-
'time_cost' => $timeCost ?? \PASSWORD_ARGON2_DEFAULT_TIME_COST,
39-
'threads' => $threads ?? \PASSWORD_ARGON2_DEFAULT_THREADS,
40-
];
41-
}
42-
}
24+
use Argon2Trait;
4325

4426
public static function isSupported()
4527
{
@@ -64,10 +46,13 @@ public function encodePassword($raw, $salt)
6446
}
6547

6648
if (\PHP_VERSION_ID >= 70200 && \defined('PASSWORD_ARGON2I')) {
67-
return $this->encodePasswordNative($raw);
68-
}
69-
if (\function_exists('sodium_crypto_pwhash_str')) {
70-
return $this->encodePasswordSodiumFunction($raw);
49+
return $this->encodePasswordNative($raw, \PASSWORD_ARGON2I);
50+
} elseif (\function_exists('sodium_crypto_pwhash_str')) {
51+
if (0 === strpos($hash = $this->encodePasswordSodiumFunction($raw), Argon2idPasswordEncoder::HASH_PREFIX)) {
52+
@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);
53+
}
54+
55+
return $hash;
7156
}
7257
if (\extension_loaded('libsodium')) {
7358
return $this->encodePasswordSodiumExtension($raw);
@@ -81,10 +66,20 @@ public function encodePassword($raw, $salt)
8166
*/
8267
public function isPasswordValid($encoded, $raw, $salt)
8368
{
84-
// If $encoded was created via "sodium_crypto_pwhash_str()", the hashing algorithm may be "argon2id" instead of "argon2i".
85-
// In this case, "password_verify()" cannot be used.
86-
if (\PHP_VERSION_ID >= 70200 && \defined('PASSWORD_ARGON2I') && (false === strpos($encoded, '$argon2id$'))) {
87-
return !$this->isPasswordTooLong($raw) && password_verify($raw, $encoded);
69+
if ($this->isPasswordTooLong($raw)) {
70+
return false;
71+
}
72+
73+
if (\PHP_VERSION_ID >= 70200 && \defined('PASSWORD_ARGON2I')) {
74+
// If $encoded was created via "sodium_crypto_pwhash_str()", the hashing algorithm may be "argon2id" instead of "argon2i"
75+
if ($isArgon2id = (0 === strpos($encoded, Argon2idPasswordEncoder::HASH_PREFIX))) {
76+
@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);
77+
}
78+
79+
// Remove the right part of the OR in 5.0
80+
if (\defined('PASSWORD_ARGON2I') || $isArgon2id && \defined('PASSWORD_ARGON2ID')) {
81+
return password_verify($raw, $encoded);
82+
}
8883
}
8984
if (\function_exists('sodium_crypto_pwhash_str_verify')) {
9085
$valid = !$this->isPasswordTooLong($raw) && \sodium_crypto_pwhash_str_verify($encoded, $raw);
@@ -102,23 +97,6 @@ public function isPasswordValid($encoded, $raw, $salt)
10297
throw new \LogicException('Argon2i algorithm is not supported. Please install the libsodium extension or upgrade to PHP 7.2+.');
10398
}
10499

105-
private function encodePasswordNative($raw)
106-
{
107-
return password_hash($raw, \PASSWORD_ARGON2I, $this->config);
108-
}
109-
110-
private function encodePasswordSodiumFunction($raw)
111-
{
112-
$hash = \sodium_crypto_pwhash_str(
113-
$raw,
114-
\SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE,
115-
\SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE
116-
);
117-
\sodium_memzero($raw);
118-
119-
return $hash;
120-
}
121-
122100
private function encodePasswordSodiumExtension($raw)
123101
{
124102
$hash = \Sodium\crypto_pwhash_str(

Encoder/Argon2idPasswordEncoder.php

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
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+
use Symfony\Component\Security\Core\Exception\LogicException;
16+
17+
/**
18+
* Hashes passwords using the Argon2id algorithm.
19+
*
20+
* @author Robin Chalas <[email protected]>
21+
*
22+
* @final
23+
*/
24+
class Argon2idPasswordEncoder extends BasePasswordEncoder implements SelfSaltingEncoderInterface
25+
{
26+
use Argon2Trait;
27+
28+
/**
29+
* @internal
30+
*/
31+
public const HASH_PREFIX = '$argon2id';
32+
33+
public static function isSupported()
34+
{
35+
return \defined('PASSWORD_ARGON2ID') || \defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13');
36+
}
37+
38+
/**
39+
* {@inheritdoc}
40+
*/
41+
public function encodePassword($raw, $salt)
42+
{
43+
if ($this->isPasswordTooLong($raw)) {
44+
throw new BadCredentialsException('Invalid password.');
45+
}
46+
if (\defined('PASSWORD_ARGON2ID')) {
47+
return $this->encodePasswordNative($raw, \PASSWORD_ARGON2ID);
48+
}
49+
if (\defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13')) {
50+
$hash = \sodium_crypto_pwhash_str(
51+
$raw,
52+
\SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE,
53+
\SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE
54+
);
55+
\sodium_memzero($raw);
56+
57+
return $hash;
58+
}
59+
60+
throw new LogicException('Algorithm "argon2id" is not supported. Please install the libsodium extension or upgrade to PHP 7.3+.');
61+
}
62+
63+
/**
64+
* {@inheritdoc}
65+
*/
66+
public function isPasswordValid($encoded, $raw, $salt)
67+
{
68+
if (0 !== strpos($encoded, self::HASH_PREFIX)) {
69+
return false;
70+
}
71+
72+
if (\defined('PASSWORD_ARGON2ID')) {
73+
return !$this->isPasswordTooLong($raw) && password_verify($raw, $encoded);
74+
}
75+
76+
if (\function_exists('sodium_crypto_pwhash_str_verify')) {
77+
$valid = !$this->isPasswordTooLong($raw) && \sodium_crypto_pwhash_str_verify($encoded, $raw);
78+
\sodium_memzero($raw);
79+
80+
return $valid;
81+
}
82+
83+
throw new LogicException('Algorithm "argon2id" is not supported. Please install the libsodium extension or upgrade to PHP 7.3+.');
84+
}
85+
}

Encoder/EncoderFactory.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,15 @@ private function getEncoderConfigFromAlgorithm($config)
117117
$config['threads'],
118118
],
119119
];
120+
case 'argon2id':
121+
return [
122+
'class' => Argon2idPasswordEncoder::class,
123+
'arguments' => [
124+
$config['memory_cost'],
125+
$config['time_cost'],
126+
$config['threads'],
127+
],
128+
];
120129
}
121130

122131
return [

Tests/Encoder/Argon2iPasswordEncoderTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ class Argon2iPasswordEncoderTest extends TestCase
2323

2424
protected function setUp()
2525
{
26-
if (!Argon2iPasswordEncoder::isSupported()) {
26+
if (!Argon2iPasswordEncoder::isSupported() || \defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13')) {
2727
$this->markTestSkipped('Argon2i algorithm is not supported.');
2828
}
2929
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
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\Argon2idPasswordEncoder;
16+
17+
class Argon2idPasswordEncoderTest extends TestCase
18+
{
19+
protected function setUp()
20+
{
21+
if (!Argon2idPasswordEncoder::isSupported()) {
22+
$this->markTestSkipped('Argon2i algorithm is not supported.');
23+
}
24+
}
25+
26+
public function testValidationWithConfig()
27+
{
28+
$encoder = new Argon2idPasswordEncoder(8, 4, 1);
29+
$result = $encoder->encodePassword('password', null);
30+
$this->assertTrue($encoder->isPasswordValid($result, 'password', null));
31+
$this->assertFalse($encoder->isPasswordValid($result, 'anotherPassword', null));
32+
}
33+
34+
public function testValidation()
35+
{
36+
$encoder = new Argon2idPasswordEncoder();
37+
$result = $encoder->encodePassword('password', null);
38+
$this->assertTrue($encoder->isPasswordValid($result, 'password', null));
39+
$this->assertFalse($encoder->isPasswordValid($result, 'anotherPassword', null));
40+
}
41+
42+
/**
43+
* @expectedException \Symfony\Component\Security\Core\Exception\BadCredentialsException
44+
*/
45+
public function testEncodePasswordLength()
46+
{
47+
$encoder = new Argon2idPasswordEncoder();
48+
$encoder->encodePassword(str_repeat('a', 4097), 'salt');
49+
}
50+
51+
public function testCheckPasswordLength()
52+
{
53+
$encoder = new Argon2idPasswordEncoder();
54+
$result = $encoder->encodePassword(str_repeat('a', 4096), null);
55+
$this->assertFalse($encoder->isPasswordValid($result, str_repeat('a', 4097), null));
56+
$this->assertTrue($encoder->isPasswordValid($result, str_repeat('a', 4096), null));
57+
}
58+
59+
public function testUserProvidedSaltIsNotUsed()
60+
{
61+
$encoder = new Argon2idPasswordEncoder();
62+
$result = $encoder->encodePassword('password', 'salt');
63+
$this->assertTrue($encoder->isPasswordValid($result, 'password', 'anotherSalt'));
64+
}
65+
}

0 commit comments

Comments
 (0)