Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,11 +1,27 @@
using Grand.Business.Core.Interfaces.Common.Security;
using Grand.Domain.Customers;
using Grand.Infrastructure.Configuration;
using System.Security.Cryptography;
Comment thread
KrzysztofPajak marked this conversation as resolved.

namespace Grand.Business.Common.Services.Security;

public class EncryptionService : IEncryptionService
{
private const string Pbkdf2Prefix = "PBKDF2";
private const int Pbkdf2SaltSize = 16;
private const int Pbkdf2HashSize = 32;
private const int DefaultIterations = 210_000;

private readonly SecurityConfig _securityConfig;

public EncryptionService(SecurityConfig securityConfig = null)
{
_securityConfig = securityConfig ?? new SecurityConfig();
}

private int Iterations =>
_securityConfig.PasswordHashIterations > 0 ? _securityConfig.PasswordHashIterations : DefaultIterations;

/// <summary>
/// Create salt key
/// </summary>
Expand Down Expand Up @@ -35,7 +51,7 @@
{
var saltAndPassword = string.Concat(password, saltKey);
HashAlgorithm algorithm = passwordFormat switch {
HashedPasswordFormat.SHA1 => SHA1.Create(),

Check warning on line 54 in src/Business/Grand.Business.Common/Services/Security/EncryptionService.cs

View workflow job for this annotation

GitHub Actions / Build and analyze

Make sure this weak hash algorithm is not used in a sensitive context here.
HashedPasswordFormat.SHA256 => SHA256.Create(),
HashedPasswordFormat.SHA384 => SHA384.Create(),
HashedPasswordFormat.SHA512 => SHA512.Create(),
Expand All @@ -60,9 +76,9 @@
return plainText;

if (string.IsNullOrEmpty(privateKey) || privateKey.Length != 24)
throw new Exception("Wrong private key");

Check warning on line 79 in src/Business/Grand.Business.Common/Services/Security/EncryptionService.cs

View workflow job for this annotation

GitHub Actions / Build and analyze

'System.Exception' should not be thrown by user code.

var tDes = TripleDES.Create();

Check warning on line 81 in src/Business/Grand.Business.Common/Services/Security/EncryptionService.cs

View workflow job for this annotation

GitHub Actions / Build and analyze

Use a strong cipher algorithm.

tDes.Key = new ASCIIEncoding().GetBytes(privateKey);
tDes.IV = new ASCIIEncoding().GetBytes(privateKey[^8..]);
Expand All @@ -83,9 +99,9 @@
return cipherText;

if (string.IsNullOrEmpty(encryptionPrivateKey) || encryptionPrivateKey.Length != 24)
throw new Exception("Wrong encrypt private key");

Check warning on line 102 in src/Business/Grand.Business.Common/Services/Security/EncryptionService.cs

View workflow job for this annotation

GitHub Actions / Build and analyze

'System.Exception' should not be thrown by user code.

var tDes = TripleDES.Create();

Check warning on line 104 in src/Business/Grand.Business.Common/Services/Security/EncryptionService.cs

View workflow job for this annotation

GitHub Actions / Build and analyze

Use a strong cipher algorithm.
tDes.Key = new ASCIIEncoding().GetBytes(encryptionPrivateKey);
tDes.IV = new ASCIIEncoding().GetBytes(encryptionPrivateKey[^8..]);

Expand All @@ -93,12 +109,106 @@
return DecryptTextFromMemory(buffer, tDes.Key, tDes.IV);
}

/// <summary>
/// Creates a strong, self-describing PBKDF2 (HMAC-SHA256) password hash.
/// Format: PBKDF2$1$&lt;iterations&gt;$&lt;saltBase64&gt;$&lt;hashBase64&gt;
/// </summary>
public virtual string HashPassword(string password)
{
ArgumentNullException.ThrowIfNull(password);

var salt = RandomNumberGenerator.GetBytes(Pbkdf2SaltSize);
var iterations = Iterations;
var hash = Rfc2898DeriveBytes.Pbkdf2(
PepperedPassword(password), salt, iterations, HashAlgorithmName.SHA256, Pbkdf2HashSize);

return string.Join('$',
Pbkdf2Prefix, "1", iterations.ToString(), Convert.ToBase64String(salt), Convert.ToBase64String(hash));
}

public virtual bool VerifyPassword(string enteredPassword, PasswordFormat passwordFormat, string storedPassword,
string storedSalt, HashedPasswordFormat legacyHashedFormat)
{
if (enteredPassword == null || storedPassword == null)
return false;

switch (passwordFormat)
{
case PasswordFormat.Clear:
return FixedTimeEquals(enteredPassword, storedPassword);
case PasswordFormat.Encrypted:
return FixedTimeEquals(EncryptText(enteredPassword, storedSalt), storedPassword);
case PasswordFormat.Hashed:
return IsPbkdf2Hash(storedPassword)
? VerifyPbkdf2(enteredPassword, storedPassword)
: FixedTimeEquals(CreatePasswordHash(enteredPassword, storedSalt, legacyHashedFormat),
storedPassword);
default:
return false;
}
}

public virtual bool PasswordHashNeedsUpgrade(PasswordFormat passwordFormat, string storedPassword)
{
//up to date only if it is a PBKDF2 hash whose embedded cost meets the currently configured iteration count;
//everything else (Clear/Encrypted, legacy SHA, or a weaker/unparseable PBKDF2 hash) is upgraded on next login
if (passwordFormat != PasswordFormat.Hashed || !IsPbkdf2Hash(storedPassword))
return true;

var parts = storedPassword.Split('$');
return parts.Length != 5 || !int.TryParse(parts[2], out var iterations) || iterations < Iterations;
}

private static bool IsPbkdf2Hash(string storedPassword)
{
return storedPassword != null && storedPassword.StartsWith(Pbkdf2Prefix + "$", StringComparison.Ordinal);
}

private bool VerifyPbkdf2(string enteredPassword, string storedPassword)
{
//PBKDF2$1$<iterations>$<saltBase64>$<hashBase64>
var parts = storedPassword.Split('$');
if (parts.Length != 5 || !int.TryParse(parts[2], out var iterations) || iterations <= 0)
return false;

try
{
var salt = Convert.FromBase64String(parts[3]);
var expected = Convert.FromBase64String(parts[4]);
if (expected.Length == 0)
return false;

var actual = Rfc2898DeriveBytes.Pbkdf2(
PepperedPassword(enteredPassword), salt, iterations, HashAlgorithmName.SHA256, expected.Length);
return CryptographicOperations.FixedTimeEquals(actual, expected);
}
catch (FormatException)
{
//salt/hash are not valid Base64 (corrupted stored value) - fail closed on the auth path.
//Other arguments cannot throw here: iterations/output length are guarded above and the rest are constant.
return false;
}
Comment thread
KrzysztofPajak marked this conversation as resolved.
}

private byte[] PepperedPassword(string password)
{
var pepper = _securityConfig.PasswordHashKey;
//a NUL separator prevents ambiguity between password and pepper boundaries
return Encoding.UTF8.GetBytes(string.IsNullOrEmpty(pepper) ? password : password + "\0" + pepper);
}

private static bool FixedTimeEquals(string a, string b)
{
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(a), Encoding.UTF8.GetBytes(b));
}

#region Utilities

private static byte[] EncryptTextToMemory(string data, byte[] key, byte[] iv)
{
using var ms = new MemoryStream();
using (var cs = new CryptoStream(ms, TripleDES.Create().CreateEncryptor(key, iv), CryptoStreamMode.Write))

Check warning on line 211 in src/Business/Grand.Business.Common/Services/Security/EncryptionService.cs

View workflow job for this annotation

GitHub Actions / Build and analyze

Use a strong cipher algorithm.
{
var toEncrypt = new UnicodeEncoding().GetBytes(data);
cs.Write(toEncrypt, 0, toEncrypt.Length);
Expand All @@ -111,7 +221,7 @@
private static string DecryptTextFromMemory(byte[] data, byte[] key, byte[] iv)
{
using var ms = new MemoryStream(data);
using var cs = new CryptoStream(ms, TripleDES.Create().CreateDecryptor(key, iv), CryptoStreamMode.Read);

Check warning on line 224 in src/Business/Grand.Business.Common/Services/Security/EncryptionService.cs

View workflow job for this annotation

GitHub Actions / Build and analyze

Use a strong cipher algorithm.
var sr = new StreamReader(cs, new UnicodeEncoding());
return sr.ReadLine();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,35 @@ public interface IEncryptionService
string CreatePasswordHash(string password, string saltKey,
HashedPasswordFormat passwordFormat = HashedPasswordFormat.SHA1);

/// <summary>
/// Creates a strong, self-describing password hash for the current default algorithm (PBKDF2/HMAC-SHA256).
/// The salt and parameters are embedded in the returned value, so a separate salt field is not needed and
/// verification never depends on a global setting. Store the result in <c>Customer.Password</c> with
/// <see cref="PasswordFormat.Hashed" />.
/// </summary>
/// <param name="password">Plain-text password</param>
/// <returns>Self-describing hash string</returns>
string HashPassword(string password);

/// <summary>
/// Verifies an entered password against a stored credential in a format-aware, constant-time way.
/// Handles Clear, Encrypted, the modern PBKDF2 hash and legacy SHA-x hashes transparently.
/// </summary>
/// <param name="enteredPassword">Plain-text password supplied by the user</param>
/// <param name="passwordFormat">Stored password format (<c>Customer.PasswordFormatId</c>)</param>
/// <param name="storedPassword">Stored password/hash (<c>Customer.Password</c>)</param>
/// <param name="storedSalt">Stored salt (<c>Customer.PasswordSalt</c>); ignored for PBKDF2</param>
/// <param name="legacyHashedFormat">Hash algorithm used by legacy SHA hashes (global setting)</param>
/// <returns>True when the password matches</returns>
bool VerifyPassword(string enteredPassword, PasswordFormat passwordFormat, string storedPassword,
string storedSalt, HashedPasswordFormat legacyHashedFormat);

/// <summary>
/// Indicates whether a stored credential uses a weak/legacy format (Clear, Encrypted or a non-PBKDF2 hash)
/// and should be transparently re-hashed to the modern format after the next successful authentication.
/// </summary>
bool PasswordHashNeedsUpgrade(PasswordFormat passwordFormat, string storedPassword);

/// <summary>
/// Encrypt text
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,9 @@ public CustomerManagerService(
public virtual bool PasswordMatch(PasswordFormat passwordFormat, string oldPassword, string newPassword,
string passwordSalt)
{
var newPwd = passwordFormat switch {
PasswordFormat.Clear => newPassword,
PasswordFormat.Encrypted => _encryptionService.EncryptText(newPassword, passwordSalt),
PasswordFormat.Hashed => _encryptionService.CreatePasswordHash(newPassword, passwordSalt,
_customerSettings.HashedPasswordFormat),
_ => throw new Exception("PasswordFormat not supported")
};
return oldPassword.Equals(newPwd);
//oldPassword is the stored credential, newPassword is the plain-text candidate being checked against it
return _encryptionService.VerifyPassword(newPassword, passwordFormat, oldPassword, passwordSalt,
_customerSettings.HashedPasswordFormat);
}


Expand All @@ -84,17 +79,23 @@ public virtual async Task<CustomerLoginResults> LoginCustomer(string usernameOrE
? await _customerService.GetCustomerByUsername(usernameOrEmail, storeId)
: await _customerService.GetCustomerByEmail(usernameOrEmail, storeId);

var pwd = customer.PasswordFormatId switch {
PasswordFormat.Clear => password,
PasswordFormat.Encrypted => _encryptionService.EncryptText(password, customer.PasswordSalt),
PasswordFormat.Hashed => _encryptionService.CreatePasswordHash(password, customer.PasswordSalt,
_customerSettings.HashedPasswordFormat),
_ => throw new Exception("PasswordFormat not supported")
};
var isValid = pwd == customer.Password;
var isValid = _encryptionService.VerifyPassword(password, customer.PasswordFormatId, customer.Password,
customer.PasswordSalt, _customerSettings.HashedPasswordFormat);
if (!isValid)
return CustomerLoginResults.WrongPassword;

//transparently migrate weak/legacy hashes (SHA-x, Clear, Encrypted) to the modern PBKDF2 format
if (_encryptionService.PasswordHashNeedsUpgrade(customer.PasswordFormatId, customer.Password))
{
try
{
await UpgradePasswordHash(customer, password);
}
catch
{
//best-effort upgrade only; don't block login if persisting the upgraded hash fails
}
Comment thread
KrzysztofPajak marked this conversation as resolved.
Dismissed
}
//2fa required
if (customer.GetUserFieldFromEntity<bool>(SystemCustomerFieldNames.TwoFactorEnabled) &&
_customerSettings.TwoFactorAuthenticationEnabled)
Expand Down Expand Up @@ -134,10 +135,9 @@ public virtual async Task RegisterCustomer(RegistrationRequest request)
_encryptionService.EncryptText(request.Password, request.Customer.PasswordSalt);
break;
case PasswordFormat.Hashed:
var saltKey = _encryptionService.CreateSaltKey(5);
request.Customer.PasswordSalt = saltKey;
request.Customer.Password = _encryptionService.CreatePasswordHash(request.Password, saltKey,
_customerSettings.HashedPasswordFormat);
//modern self-describing PBKDF2 hash - salt and parameters are embedded in the value itself
request.Customer.PasswordSalt = string.Empty;
request.Customer.Password = _encryptionService.HashPassword(request.Password);
break;
}

Expand Down Expand Up @@ -190,10 +190,9 @@ public virtual async Task ChangePassword(ChangePasswordRequest request, string s
break;
case PasswordFormat.Hashed:
{
var saltKey = _encryptionService.CreateSaltKey(5);
customer.PasswordSalt = saltKey;
customer.Password = _encryptionService.CreatePasswordHash(request.NewPassword, saltKey,
_customerSettings.HashedPasswordFormat);
//modern self-describing PBKDF2 hash - salt and parameters are embedded in the value itself
customer.PasswordSalt = string.Empty;
customer.Password = _encryptionService.HashPassword(request.NewPassword);
}
break;
}
Expand All @@ -208,5 +207,18 @@ public virtual async Task ChangePassword(ChangePasswordRequest request, string s
await _customerService.UpdateUserField(customer, SystemCustomerFieldNames.PasswordToken, Guid.NewGuid().ToString());
}

/// <summary>
/// Re-hashes an already-verified plain-text password to the modern PBKDF2 format and persists it.
/// Called right after a successful authentication against a weak/legacy credential, so no password reset is
/// required and no schema change is involved (the value simply moves to the self-describing hash format).
/// </summary>
private async Task UpgradePasswordHash(Customer customer, string plainPassword)
{
customer.Password = _encryptionService.HashPassword(plainPassword);
customer.PasswordSalt = string.Empty;
customer.PasswordFormatId = PasswordFormat.Hashed;
await _customerService.UpdateCustomer(customer);
}

#endregion
}
15 changes: 15 additions & 0 deletions src/Core/Grand.Infrastructure/Configuration/SecurityConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,19 @@ public class SecurityConfig
/// using menu configuration.
/// </summary>
public bool AuthorizeAdminMenu { get; set; }

/// <summary>
/// Server-side secret ("pepper") mixed into the PBKDF2 password hash. Optional but recommended: it must be stored
/// outside the database (appsettings/secret store), so a database-only leak is not enough to verify hashes offline.
/// Leave empty to hash without a pepper.
/// IMPORTANT: changing this value invalidates all existing PBKDF2 hashes (affected customers must reset their
/// password); legacy SHA hashes are unaffected. Set it once, before going live.
/// </summary>
public string PasswordHashKey { get; set; }

/// <summary>
/// PBKDF2 (HMAC-SHA256) iteration count for newly created/upgraded password hashes. Default 210000 (OWASP 2023).
/// The value is embedded in each stored hash, so raising it later does not break existing hashes.
/// </summary>
public int PasswordHashIterations { get; set; }
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Grand.Business.Common.Services.Security;
using Grand.Domain.Customers;
using Grand.Infrastructure.Configuration;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace Grand.Business.Common.Tests.Services.Security;
Expand Down Expand Up @@ -93,4 +94,83 @@ public void DecryptText_InvalidPrivateKeyLength_ThrowException()
var toDescrypt = "gdfgdfgt45gfdfg";
Assert.ThrowsExactly<Exception>(() => _encryptionService.DecryptText(toDescrypt, privateKey));
}

[TestMethod]
public void HashPassword_ProducesSelfDescribingSaltedHash_ThatVerifies()
{
var hash1 = _encryptionService.HashPassword("password");
var hash2 = _encryptionService.HashPassword("password");

//self-describing PBKDF2 format and a random salt per call
StringAssert.StartsWith(hash1, "PBKDF2$");
Assert.AreNotEqual(hash1, hash2);

Assert.IsTrue(_encryptionService.VerifyPassword("password", PasswordFormat.Hashed, hash1, string.Empty,
HashedPasswordFormat.SHA1));
Assert.IsFalse(_encryptionService.VerifyPassword("wrong", PasswordFormat.Hashed, hash1, string.Empty,
HashedPasswordFormat.SHA1));
}

[TestMethod]
public void VerifyPassword_VerifiesLegacyShaHash_WithoutRehashing()
{
var salt = _encryptionService.CreateSaltKey(16);
var legacy = _encryptionService.CreatePasswordHash("password", salt, HashedPasswordFormat.SHA512);

Assert.IsTrue(_encryptionService.VerifyPassword("password", PasswordFormat.Hashed, legacy, salt,
HashedPasswordFormat.SHA512));
Assert.IsFalse(_encryptionService.VerifyPassword("password", PasswordFormat.Hashed, legacy, salt,
HashedPasswordFormat.SHA256));
}

[TestMethod]
public void PasswordHashNeedsUpgrade_TrueForLegacyOrReversible_FalseForPbkdf2()
{
var pbkdf2 = _encryptionService.HashPassword("password");
Assert.IsFalse(_encryptionService.PasswordHashNeedsUpgrade(PasswordFormat.Hashed, pbkdf2));

Assert.IsTrue(_encryptionService.PasswordHashNeedsUpgrade(PasswordFormat.Hashed, "5FDEFB16C983"));
Assert.IsTrue(_encryptionService.PasswordHashNeedsUpgrade(PasswordFormat.Clear, "password"));
Assert.IsTrue(_encryptionService.PasswordHashNeedsUpgrade(PasswordFormat.Encrypted, "cipher"));
}

[TestMethod]
public void PasswordHashNeedsUpgrade_TrueWhenEmbeddedIterationsBelowConfigured()
{
//hash created at the default cost, then verified against a service configured with a higher cost
var weakHash = _encryptionService.HashPassword("password");
var stronger = new EncryptionService(new SecurityConfig { PasswordHashIterations = 400_000 });

Assert.IsTrue(stronger.PasswordHashNeedsUpgrade(PasswordFormat.Hashed, weakHash));
Assert.IsFalse(stronger.PasswordHashNeedsUpgrade(PasswordFormat.Hashed, stronger.HashPassword("password")));
}

[TestMethod]
public void VerifyPassword_Pbkdf2_FailsClosedOnMalformedHash()
{
//corrupted stored hashes must not throw on the auth path - they simply do not match
foreach (var malformed in new[]
{
"PBKDF2$1$0$YQ==$YQ==", //zero iterations
"PBKDF2$1$-5$YQ==$YQ==", //negative iterations
"PBKDF2$1$210000$not-base64$YQ==", //invalid salt
"PBKDF2$1$210000$YQ==", //too few segments
"PBKDF2$1$210000$YQ==$" //empty hash
})
Assert.IsFalse(_encryptionService.VerifyPassword("password", PasswordFormat.Hashed, malformed,
string.Empty, HashedPasswordFormat.SHA1), $"should fail closed for: {malformed}");
}

[TestMethod]
public void VerifyPassword_Pbkdf2_IsPepperSensitive()
{
var peppered = new EncryptionService(new SecurityConfig { PasswordHashKey = "server-pepper" });
var hash = peppered.HashPassword("password");

//same pepper verifies, missing/different pepper does not
Assert.IsTrue(peppered.VerifyPassword("password", PasswordFormat.Hashed, hash, string.Empty,
HashedPasswordFormat.SHA1));
Assert.IsFalse(_encryptionService.VerifyPassword("password", PasswordFormat.Hashed, hash, string.Empty,
HashedPasswordFormat.SHA1));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ public void Init()
_customerServiceMock = new Mock<ICustomerService>();
_groupServiceMock = new Mock<IGroupService>();
_encryptionServiceMock = new Mock<IEncryptionService>();
//emulate the real format-aware verification for the Clear-format credentials used by these tests
_encryptionServiceMock.Setup(e => e.VerifyPassword(It.IsAny<string>(), It.IsAny<PasswordFormat>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<HashedPasswordFormat>()))
.Returns((string entered, PasswordFormat _, string stored, string _, HashedPasswordFormat _) =>
entered == stored);
_mediatorMock = new Mock<IMediator>();
_customerHistoryPasswordServiceMock = new Mock<ICustomerHistoryPasswordService>();
_customerSettings = new CustomerSettings {
Expand Down
Loading
Loading