Skip to content

Commit eb2d36a

Browse files
Merge pull request #4125 from magento-arcticfoxes/2.3-qwerty-pr
Fixed issues: - MC-5843: Add Argon2ID support
2 parents 0c8932c + e8c98e3 commit eb2d36a

File tree

5 files changed

+131
-17
lines changed

5 files changed

+131
-17
lines changed

app/code/Magento/Customer/Console/Command/UpgradeHashAlgorithmCommand.php

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
use Symfony\Component\Console\Input\InputInterface;
1414
use Symfony\Component\Console\Output\OutputInterface;
1515

16+
/**
17+
* Upgrade users passwords to the new algorithm
18+
*/
1619
class UpgradeHashAlgorithmCommand extends Command
1720
{
1821
/**
@@ -65,8 +68,11 @@ protected function execute(InputInterface $input, OutputInterface $output)
6568
$customer->load($customer->getId());
6669
if (!$this->encryptor->validateHashVersion($customer->getPasswordHash())) {
6770
list($hash, $salt, $version) = explode(Encryptor::DELIMITER, $customer->getPasswordHash(), 3);
68-
$version .= Encryptor::DELIMITER . Encryptor::HASH_VERSION_LATEST;
69-
$customer->setPasswordHash($this->encryptor->getHash($hash, $salt, $version));
71+
$version .= Encryptor::DELIMITER . $this->encryptor->getLatestHashVersion();
72+
$hash = $this->encryptor->getHash($hash, $salt, $this->encryptor->getLatestHashVersion());
73+
list($hash, $salt) = explode(Encryptor::DELIMITER, $hash, 3);
74+
$hash = implode(Encryptor::DELIMITER, [$hash, $salt, $version]);
75+
$customer->setPasswordHash($hash);
7076
$customer->save();
7177
$output->write(".");
7278
}

app/code/Magento/Customer/Model/AccountManagement.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,7 @@ public function authenticate($username, $password)
554554
}
555555
try {
556556
$this->getAuthentication()->authenticate($customerId, $password);
557+
// phpcs:ignore Magento2.Exceptions.ThrowCatch
557558
} catch (InvalidEmailOrPasswordException $e) {
558559
throw new InvalidEmailOrPasswordException(__('Invalid login or password.'));
559560
}
@@ -894,6 +895,7 @@ public function createAccountWithPasswordHash(CustomerInterface $customer, $hash
894895
throw new InputMismatchException(
895896
__('A customer with the same email address already exists in an associated website.')
896897
);
898+
// phpcs:ignore Magento2.Exceptions.ThrowCatch
897899
} catch (LocalizedException $e) {
898900
throw $e;
899901
}
@@ -910,6 +912,7 @@ public function createAccountWithPasswordHash(CustomerInterface $customer, $hash
910912
}
911913
}
912914
$this->customerRegistry->remove($customer->getId());
915+
// phpcs:ignore Magento2.Exceptions.ThrowCatch
913916
} catch (InputException $e) {
914917
$this->customerRepository->delete($customer);
915918
throw $e;
@@ -1012,6 +1015,7 @@ private function changePasswordForCustomer($customer, $currentPassword, $newPass
10121015
{
10131016
try {
10141017
$this->getAuthentication()->authenticate($customer->getId(), $currentPassword);
1018+
// phpcs:ignore Magento2.Exceptions.ThrowCatch
10151019
} catch (InvalidEmailOrPasswordException $e) {
10161020
throw new InvalidEmailOrPasswordException(
10171021
__("The password doesn't match this account. Verify the password and try again.")
@@ -1071,6 +1075,7 @@ public function validate(CustomerInterface $customer)
10711075
$result = $this->getEavValidator()->isValid($customerModel);
10721076
if ($result === false && is_array($this->getEavValidator()->getMessages())) {
10731077
return $validationResults->setIsValid(false)->setMessages(
1078+
// phpcs:ignore Magento2.Functions.DiscouragedFunction
10741079
call_user_func_array(
10751080
'array_merge',
10761081
$this->getEavValidator()->getMessages()
@@ -1532,7 +1537,7 @@ protected function getFullCustomerObject($customer)
15321537
*/
15331538
public function getPasswordHash($password)
15341539
{
1535-
return $this->encryptor->getHash($password);
1540+
return $this->encryptor->getHash($password, true);
15361541
}
15371542

15381543
/**

dev/tests/integration/testsuite/Magento/User/Model/UserTest.php

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
namespace Magento\User\Model;
88

99
use Magento\Framework\Serialize\Serializer\Json;
10+
use Magento\Framework\Encryption\Encryptor;
1011

1112
/**
1213
* @magentoAppArea adminhtml
@@ -33,6 +34,11 @@ class UserTest extends \PHPUnit\Framework\TestCase
3334
*/
3435
private $serializer;
3536

37+
/**
38+
* @var Encryptor
39+
*/
40+
private $encryptor;
41+
3642
protected function setUp()
3743
{
3844
$this->_model = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create(
@@ -44,6 +50,9 @@ protected function setUp()
4450
$this->serializer = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create(
4551
Json::class
4652
);
53+
$this->encryptor = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create(
54+
Encryptor::class
55+
);
4756
}
4857

4958
/**
@@ -104,6 +113,9 @@ public function testUpdateRoleOnSave()
104113
$this->assertEquals('admin_role', $this->_model->getRole()->getRoleName());
105114
}
106115

116+
/**
117+
* phpcs:disable Magento2.Functions.StaticFunction
118+
*/
107119
public static function roleDataFixture()
108120
{
109121
self::$_newRole = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create(
@@ -335,6 +347,9 @@ public function testBeforeSaveRequiredFieldsValidation()
335347
*/
336348
public function testBeforeSavePasswordHash()
337349
{
350+
$pattern = $this->encryptor->getLatestHashVersion() === Encryptor::HASH_VERSION_ARGON2ID13 ?
351+
'/^[0-9a-f]+:[0-9a-zA-Z]{16}:[0-9]+$/' :
352+
'/^[0-9a-f]+:[0-9a-zA-Z]{32}:[0-9]+$/';
338353
$this->_model->setUsername(
339354
'john.doe'
340355
)->setFirstname(
@@ -349,7 +364,7 @@ public function testBeforeSavePasswordHash()
349364
$this->_model->save();
350365
$this->assertNotContains('123123q', $this->_model->getPassword(), 'Password is expected to be hashed');
351366
$this->assertRegExp(
352-
'/^[0-9a-f]+:[0-9a-zA-Z]{32}:[0-9]+$/',
367+
$pattern,
353368
$this->_model->getPassword(),
354369
'Salt is expected to be saved along with the password'
355370
);

lib/internal/Magento/Framework/Encryption/Encryptor.php

Lines changed: 82 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,17 @@ class Encryptor implements EncryptorInterface
3131
*/
3232
const HASH_VERSION_SHA256 = 1;
3333

34+
/**
35+
* Key of Argon2ID13 algorithm
36+
*/
37+
public const HASH_VERSION_ARGON2ID13 = 2;
38+
3439
/**
3540
* Key of latest used algorithm
41+
* @deprecated
42+
* @see \Magento\Framework\Encryption\Encryptor::getLatestHashVersion
3643
*/
37-
const HASH_VERSION_LATEST = 1;
44+
const HASH_VERSION_LATEST = 2;
3845

3946
/**
4047
* Default length of salt in bytes
@@ -87,7 +94,7 @@ class Encryptor implements EncryptorInterface
8794
private $passwordHashMap = [
8895
self::PASSWORD_HASH => '',
8996
self::PASSWORD_SALT => '',
90-
self::PASSWORD_VERSION => self::HASH_VERSION_LATEST
97+
self::PASSWORD_VERSION => self::HASH_VERSION_SHA256
9198
];
9299

93100
/**
@@ -123,6 +130,7 @@ class Encryptor implements EncryptorInterface
123130

124131
/**
125132
* Encryptor constructor.
133+
*
126134
* @param Random $random
127135
* @param DeploymentConfig $deploymentConfig
128136
* @param KeyValidator|null $keyValidator
@@ -138,6 +146,25 @@ public function __construct(
138146
$this->keys = preg_split('/\s+/s', trim((string)$deploymentConfig->get(self::PARAM_CRYPT_KEY)));
139147
$this->keyVersion = count($this->keys) - 1;
140148
$this->keyValidator = $keyValidator ?: ObjectManager::getInstance()->get(KeyValidator::class);
149+
$latestHashVersion = $this->getLatestHashVersion();
150+
if ($latestHashVersion === self::HASH_VERSION_ARGON2ID13) {
151+
$this->hashVersionMap[self::HASH_VERSION_ARGON2ID13] = SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13;
152+
$this->passwordHashMap[self::PASSWORD_VERSION] = self::HASH_VERSION_ARGON2ID13;
153+
}
154+
}
155+
156+
/**
157+
* Gets latest hash algorithm version.
158+
*
159+
* @return int
160+
*/
161+
public function getLatestHashVersion(): int
162+
{
163+
if (extension_loaded('sodium')) {
164+
return self::HASH_VERSION_ARGON2ID13;
165+
}
166+
167+
return self::HASH_VERSION_SHA256;
141168
}
142169

143170
/**
@@ -160,6 +187,7 @@ public function validateCipher($version)
160187

161188
$version = (int)$version;
162189
if (!in_array($version, $types, true)) {
190+
// phpcs:ignore Magento2.Exceptions.DirectThrow
163191
throw new \Exception((string)new \Magento\Framework\Phrase('Not supported cipher version'));
164192
}
165193
return $version;
@@ -170,20 +198,34 @@ public function validateCipher($version)
170198
*/
171199
public function getHash($password, $salt = false, $version = self::HASH_VERSION_LATEST)
172200
{
201+
if (!isset($this->hashVersionMap[$version])) {
202+
$version = self::HASH_VERSION_SHA256;
203+
}
204+
173205
if ($salt === false) {
206+
$version = $version === self::HASH_VERSION_ARGON2ID13 ? self::HASH_VERSION_SHA256 : $version;
174207
return $this->hash($password, $version);
175208
}
176209
if ($salt === true) {
177210
$salt = self::DEFAULT_SALT_LENGTH;
178211
}
179212
if (is_integer($salt)) {
213+
$salt = $version === self::HASH_VERSION_ARGON2ID13 ?
214+
SODIUM_CRYPTO_PWHASH_SALTBYTES :
215+
$salt;
180216
$salt = $this->random->getRandomString($salt);
181217
}
182218

219+
if ($version === self::HASH_VERSION_ARGON2ID13) {
220+
$hash = $this->getArgonHash($password, $salt);
221+
} else {
222+
$hash = $this->hash($salt . $password, $version);
223+
}
224+
183225
return implode(
184226
self::DELIMITER,
185227
[
186-
$this->hash($salt . $password, $version),
228+
$hash,
187229
$salt,
188230
$version
189231
]
@@ -193,7 +235,7 @@ public function getHash($password, $salt = false, $version = self::HASH_VERSION_
193235
/**
194236
* @inheritdoc
195237
*/
196-
public function hash($data, $version = self::HASH_VERSION_LATEST)
238+
public function hash($data, $version = self::HASH_VERSION_SHA256)
197239
{
198240
return hash($this->hashVersionMap[$version], (string)$data);
199241
}
@@ -214,7 +256,11 @@ public function isValidHash($password, $hash)
214256
$this->explodePasswordHash($hash);
215257

216258
foreach ($this->getPasswordVersion() as $hashVersion) {
217-
$password = $this->hash($this->getPasswordSalt() . $password, $hashVersion);
259+
if ($hashVersion === self::HASH_VERSION_ARGON2ID13) {
260+
$password = $this->getArgonHash($password, $this->getPasswordSalt());
261+
} else {
262+
$password = $this->hash($this->getPasswordSalt() . $password, $hashVersion);
263+
}
218264
}
219265

220266
return Security::compareStrings(
@@ -232,8 +278,8 @@ public function validateHashVersion($hash, $validateCount = false)
232278
$hashVersions = $this->getPasswordVersion();
233279

234280
return $validateCount
235-
? end($hashVersions) === self::HASH_VERSION_LATEST && count($hashVersions) === 1
236-
: end($hashVersions) === self::HASH_VERSION_LATEST;
281+
? end($hashVersions) === $this->getLatestHashVersion() && count($hashVersions) === 1
282+
: end($hashVersions) === $this->getLatestHashVersion();
237283
}
238284

239285
/**
@@ -384,6 +430,7 @@ public function decrypt($data)
384430
public function validateKey($key)
385431
{
386432
if (!$this->keyValidator->isValid($key)) {
433+
// phpcs:ignore Magento2.Exceptions.DirectThrow
387434
throw new \Exception(
388435
(string)new \Magento\Framework\Phrase(
389436
'Encryption key must be 32 character string without any white space.'
@@ -481,4 +528,32 @@ private function getCipherVersion()
481528
return self::CIPHER_RIJNDAEL_256;
482529
}
483530
}
531+
532+
/**
533+
* Generate Argon2ID13 hash.
534+
*
535+
* @param string $data
536+
* @param string $salt
537+
* @return string
538+
* @throws \SodiumException
539+
*/
540+
private function getArgonHash($data, $salt = ''): string
541+
{
542+
$salt = empty($salt) ?
543+
random_bytes(SODIUM_CRYPTO_PWHASH_SALTBYTES) :
544+
substr($salt, 0, SODIUM_CRYPTO_PWHASH_SALTBYTES);
545+
546+
if (strlen($salt) < SODIUM_CRYPTO_PWHASH_SALTBYTES) {
547+
$salt = str_pad($salt, SODIUM_CRYPTO_PWHASH_SALTBYTES, $salt);
548+
}
549+
550+
return bin2hex(sodium_crypto_pwhash(
551+
SODIUM_CRYPTO_SIGN_SEEDBYTES,
552+
$data,
553+
$salt,
554+
SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE,
555+
SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE,
556+
$this->hashVersionMap[self::HASH_VERSION_ARGON2ID13]
557+
));
558+
}
484559
}

lib/internal/Magento/Framework/Encryption/Test/Unit/EncryptorTest.php

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
use Magento\Framework\Encryption\KeyValidator;
1717
use Magento\Framework\TestFramework\Unit\Helper\ObjectManager;
1818

19+
/**
20+
* Test case for \Magento\Framework\Encryption\Encryptor
21+
*/
1922
class EncryptorTest extends \PHPUnit\Framework\TestCase
2023
{
2124
private const CRYPT_KEY_1 = 'g9mY9KLrcuAVJfsmVUSRkKFLDdUPVkaZ';
@@ -67,7 +70,9 @@ public function testGetHashNoSalt(): void
6770
public function testGetHashSpecifiedSalt(): void
6871
{
6972
$this->randomGeneratorMock->expects($this->never())->method('getRandomString');
70-
$expected = '13601bda4ea78e55a07b98866d2be6be0744e3866f13c00c811cab608a28f322:salt:1';
73+
$expected = $this->encryptor->getLatestHashVersion() === Encryptor::HASH_VERSION_ARGON2ID13 ?
74+
'7640855aef9cb6ffd20229601d2904a2192e372b391db8230d7faf073b393e4c:salt:2' :
75+
'13601bda4ea78e55a07b98866d2be6be0744e3866f13c00c811cab608a28f322:salt:1';
7176
$actual = $this->encryptor->getHash('password', 'salt');
7277
$this->assertEquals($expected, $actual);
7378
}
@@ -78,9 +83,11 @@ public function testGetHashRandomSaltDefaultLength(): void
7883
$this->randomGeneratorMock
7984
->expects($this->once())
8085
->method('getRandomString')
81-
->with(32)
86+
->with($this->encryptor->getLatestHashVersion() === Encryptor::HASH_VERSION_ARGON2ID13 ? 16 : 32)
8287
->willReturn($salt);
83-
$expected = 'a1c7fc88037b70c9be84d3ad12522c7888f647915db78f42eb572008422ba2fa:' . $salt . ':1';
88+
$expected = $this->encryptor->getLatestHashVersion() === Encryptor::HASH_VERSION_ARGON2ID13 ?
89+
'0be2351d7513d3e9622bd2df1891c39ba5ba6d1e3d67a058c60d6fd83f6641d8:' . $salt . ':2' :
90+
'a1c7fc88037b70c9be84d3ad12522c7888f647915db78f42eb572008422ba2fa:' . $salt . ':1';
8491
$actual = $this->encryptor->getHash('password', true);
8592
$this->assertEquals($expected, $actual);
8693
}
@@ -90,9 +97,15 @@ public function testGetHashRandomSaltSpecifiedLength(): void
9097
$this->randomGeneratorMock
9198
->expects($this->once())
9299
->method('getRandomString')
93-
->with(11)
94-
->willReturn('random_salt');
95-
$expected = '4c5cab8dd00137d11258f8f87b93fd17bd94c5026fc52d3c5af911dd177a2611:random_salt:1';
100+
->with($this->encryptor->getLatestHashVersion() === Encryptor::HASH_VERSION_ARGON2ID13 ? 16 : 11)
101+
->willReturn(
102+
$this->encryptor->getLatestHashVersion() === Encryptor::HASH_VERSION_ARGON2ID13 ?
103+
'random_salt12345' :
104+
'random_salt'
105+
);
106+
$expected = $this->encryptor->getLatestHashVersion() === Encryptor::HASH_VERSION_ARGON2ID13 ?
107+
'ca7982945fa90444b78d586678ff1c223ce13f99a39ec9541eae8b63ada3816a:random_salt12345:2' :
108+
'4c5cab8dd00137d11258f8f87b93fd17bd94c5026fc52d3c5af911dd177a2611:random_salt:1';
96109
$actual = $this->encryptor->getHash('password', 11);
97110
$this->assertEquals($expected, $actual);
98111
}

0 commit comments

Comments
 (0)