diff --git a/doc/changelogs/3.6.0.md b/doc/changelogs/3.6.0.md index 53889153e..fa9fcaa19 100644 --- a/doc/changelogs/3.6.0.md +++ b/doc/changelogs/3.6.0.md @@ -6,6 +6,8 @@ Compare: [`3.5.3...3.6.0`](https://github.com/elsa-workflows/elsa-core/compare/3 ## ⚠️ Breaking changes / upgrade notes +- **Identity secret hashing hardened**: New user passwords, client secrets, and API keys are hashed with PBKDF2-SHA256 using per-record salts and version metadata instead of fast SHA-256 hashes. Existing legacy hashes still verify and are upgraded opportunistically after successful login/API-key validation. Generated identity secrets now use cryptographic randomness. + - **EF Core package names have changed**: EF Core persistence packages were renamed from `Elsa.EntityFrameworkCore.*` to `Elsa.Persistence.EFCore.*`. If your application references any of the old package names, you must update them to the new package names when upgrading to 3.6.0. Be sure to review your project files, internal package feeds, CI pipelines, and deployment manifests for old package references. - **Database migrations required (EF Core — all providers)**: `ActivityNodeId` columns in `ActivityExecutionRecords` and `WorkflowExecutionLogRecords` have been widened to unlimited types (`nvarchar(max)` / `longtext` / `NCLOB`) to support deeply nested workflows. The corresponding B-tree indexes (`IX_ActivityExecutionRecord_ActivityNodeId`, `IX_WorkflowExecutionLogRecord_ActivityNodeId`) are dropped as part of the V3_6 migrations. Run EF Core migrations before upgrading any SQL Server, MySQL, or Oracle deployment to 3.6.0. ([71438596f3](https://github.com/elsa-workflows/elsa-core/commit/71438596f3)) ([#7338](https://github.com/elsa-workflows/elsa-core/pull/7338)) @@ -162,4 +164,4 @@ Compare: [`3.5.3...3.6.0`](https://github.com/elsa-workflows/elsa-core/compare/3 * Removed `Elsa.ServerAndStudio.Web` and related sample projects from solution. ([ecf5b390f1](https://github.com/elsa-workflows/elsa-core/commit/ecf5b390f1)) * Updated documentation to reflect .NET 10.0 support and remove deprecated external dependency references. ([7add1030c4](https://github.com/elsa-workflows/elsa-core/commit/7add1030c4)) * Added DeepWiki badge to README. ([b3ad57191a](https://github.com/elsa-workflows/elsa-core/commit/b3ad57191a)) -* Null safety and compiler warning fixes across multiple modules. ([490c8a2c9e](https://github.com/elsa-workflows/elsa-core/commit/490c8a2c9e), [2c0b3da5de](https://github.com/elsa-workflows/elsa-core/commit/2c0b3da5de)) ([#7050](https://github.com/elsa-workflows/elsa-core/pull/7050), [#7051](https://github.com/elsa-workflows/elsa-core/pull/7051)) \ No newline at end of file +* Null safety and compiler warning fixes across multiple modules. ([490c8a2c9e](https://github.com/elsa-workflows/elsa-core/commit/490c8a2c9e), [2c0b3da5de](https://github.com/elsa-workflows/elsa-core/commit/2c0b3da5de)) ([#7050](https://github.com/elsa-workflows/elsa-core/pull/7050), [#7051](https://github.com/elsa-workflows/elsa-core/pull/7051)) diff --git a/src/modules/Elsa.Identity/Contracts/ISecretHasher.cs b/src/modules/Elsa.Identity/Contracts/ISecretHasher.cs index 94dd00da7..e1ba04051 100644 --- a/src/modules/Elsa.Identity/Contracts/ISecretHasher.cs +++ b/src/modules/Elsa.Identity/Contracts/ISecretHasher.cs @@ -39,6 +39,16 @@ public interface ISecretHasher /// The salt. /// True if the secret is valid, otherwise false. bool VerifySecret(string clearTextSecret, string secret, string salt); + + /// + /// Verifies the secret. + /// + /// The secret to verify. + /// The hashed secret. + /// The salt. + /// Whether the stored hash should be upgraded. + /// True if the secret is valid, otherwise false. + bool VerifySecret(string clearTextSecret, string secret, string salt, out bool needsRehash); /// /// Verifies the secret. @@ -48,10 +58,19 @@ public interface ISecretHasher /// True if the secret is valid, otherwise false. bool VerifySecret(string clearTextSecret, HashedSecret hashedSecret); + /// + /// Verifies the secret. + /// + /// The secret to verify. + /// The hashed secret. + /// Whether the stored hash should be upgraded. + /// True if the secret is valid, otherwise false. + bool VerifySecret(string clearTextSecret, HashedSecret hashedSecret, out bool needsRehash); + /// /// Generates a salt. /// /// The size of the salt. /// The salt. byte[] GenerateSalt(int saltSize = 32) => RandomNumberGenerator.GetBytes(saltSize); -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Identity/README.md b/src/modules/Elsa.Identity/README.md index 5fec28bb4..343eedacb 100644 --- a/src/modules/Elsa.Identity/README.md +++ b/src/modules/Elsa.Identity/README.md @@ -78,3 +78,6 @@ identity.UseDefaultAdmin("admin", "password", "admin", new List { "*" }) - Prefer environment variables or a secret manager for admin credentials. - After first bootstrap, rotate credentials according to your security policy. +## Secret Hashing + +New identity passwords, client secrets, and API keys are hashed with PBKDF2-SHA256 using a per-record salt and version metadata. Existing legacy SHA-256 hashes remain valid and are upgraded opportunistically after a successful user login or API-key validation. diff --git a/src/modules/Elsa.Identity/Services/DefaultApiKeyGeneratorAndParser.cs b/src/modules/Elsa.Identity/Services/DefaultApiKeyGeneratorAndParser.cs index f1b6668f3..0bc9c0e76 100644 --- a/src/modules/Elsa.Identity/Services/DefaultApiKeyGeneratorAndParser.cs +++ b/src/modules/Elsa.Identity/Services/DefaultApiKeyGeneratorAndParser.cs @@ -1,3 +1,4 @@ +using System.Security.Cryptography; using System.Text; using Elsa.Identity.Contracts; @@ -12,7 +13,7 @@ public class DefaultApiKeyGeneratorAndParser : IApiKeyGenerator, IApiKeyParser public string Generate(string clientId) { var hexIdentifier = Convert.ToHexString(Encoding.UTF8.GetBytes(clientId)); - var id = Guid.NewGuid().ToString("D"); + var id = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)); return $"{hexIdentifier}-{id}"; } @@ -25,4 +26,4 @@ public class DefaultApiKeyGeneratorAndParser : IApiKeyGenerator, IApiKeyParser return clientId; } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Identity/Services/DefaultApplicationCredentialsValidator.cs b/src/modules/Elsa.Identity/Services/DefaultApplicationCredentialsValidator.cs index 39ea24066..176f98add 100644 --- a/src/modules/Elsa.Identity/Services/DefaultApplicationCredentialsValidator.cs +++ b/src/modules/Elsa.Identity/Services/DefaultApplicationCredentialsValidator.cs @@ -11,15 +11,17 @@ public class DefaultApplicationCredentialsValidator : IApplicationCredentialsVal { private readonly IApiKeyParser _apiKeyParser; private readonly IApplicationProvider _applicationProvider; + private readonly IApplicationStore _applicationStore; private readonly ISecretHasher _secretHasher; /// /// Initializes a new instance of the class. /// - public DefaultApplicationCredentialsValidator(IApiKeyParser apiKeyParser, IApplicationProvider applicationProvider, ISecretHasher secretHasher) + public DefaultApplicationCredentialsValidator(IApiKeyParser apiKeyParser, IApplicationProvider applicationProvider, IApplicationStore applicationStore, ISecretHasher secretHasher) { _apiKeyParser = apiKeyParser; _applicationProvider = applicationProvider; + _applicationStore = applicationStore; _secretHasher = secretHasher; } @@ -35,7 +37,19 @@ public class DefaultApplicationCredentialsValidator : IApplicationCredentialsVal if(application == null) return null; - var isValidApiKey = _secretHasher.VerifySecret(apiKey, application.HashedApiKey, application.HashedApiKeySalt); - return isValidApiKey ? application : null; + var isValidApiKey = _secretHasher.VerifySecret(apiKey, application.HashedApiKey, application.HashedApiKeySalt, out var needsRehash); + + if (!isValidApiKey) + return null; + + if (needsRehash) + { + var hashedApiKey = _secretHasher.HashSecret(apiKey); + application.HashedApiKey = hashedApiKey.EncodeSecret(); + application.HashedApiKeySalt = hashedApiKey.EncodeSalt(); + await _applicationStore.SaveAsync(application, cancellationToken); + } + + return application; } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Identity/Services/DefaultRandomStringGenerator.cs b/src/modules/Elsa.Identity/Services/DefaultRandomStringGenerator.cs index 4e41f71af..313f51405 100644 --- a/src/modules/Elsa.Identity/Services/DefaultRandomStringGenerator.cs +++ b/src/modules/Elsa.Identity/Services/DefaultRandomStringGenerator.cs @@ -1,3 +1,4 @@ +using System.Security.Cryptography; using System.Text; using Elsa.Identity.Constants; using Elsa.Identity.Contracts; @@ -7,16 +8,6 @@ namespace Elsa.Identity.Services; /// public class DefaultRandomStringGenerator : IRandomStringGenerator { - private readonly Random _random; - - /// - /// Initializes a new instance of the class. - /// - public DefaultRandomStringGenerator() - { - _random = new Random(); - } - /// public string Generate(int length = 32, char[]? chars = null) { @@ -26,10 +17,10 @@ public class DefaultRandomStringGenerator : IRandomStringGenerator for (var i = 0; i < length; i++) { - var randomIndex = _random.Next(chars.Length); + var randomIndex = RandomNumberGenerator.GetInt32(chars.Length); identifierBuilder.Append(chars[randomIndex]); } return identifierBuilder.ToString(); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Identity/Services/DefaultSecretHasher.cs b/src/modules/Elsa.Identity/Services/DefaultSecretHasher.cs index eecf0bf59..d4fa04830 100644 --- a/src/modules/Elsa.Identity/Services/DefaultSecretHasher.cs +++ b/src/modules/Elsa.Identity/Services/DefaultSecretHasher.cs @@ -8,6 +8,11 @@ namespace Elsa.Identity.Services; /// public class DefaultSecretHasher : ISecretHasher { + private const string Algorithm = "pbkdf2-sha256"; + private const char Separator = '$'; + private const int DefaultIterationCount = 210_000; + private const int KeySize = 32; + /// public HashedSecret HashSecret(string secret) { @@ -30,23 +35,82 @@ public class DefaultSecretHasher : ISecretHasher return VerifySecret(clearTextSecret, hashedPassword); } + /// + public bool VerifySecret(string clearTextSecret, string secret, string salt, out bool needsRehash) + { + var hashedPassword = HashedSecret.FromString(secret, salt); + return VerifySecret(clearTextSecret, hashedPassword, out needsRehash); + } + /// public bool VerifySecret(string clearTextSecret, HashedSecret hashedSecret) + { + return VerifySecret(clearTextSecret, hashedSecret, out _); + } + + /// + public bool VerifySecret(string clearTextSecret, HashedSecret hashedSecret, out bool needsRehash) { var password = hashedSecret.Secret; var salt = hashedSecret.Salt; - var providedHashedPassword = HashSecret(clearTextSecret, salt); - return providedHashedPassword.Secret.SequenceEqual(password); + var passwordBytes = Encoding.UTF8.GetBytes(clearTextSecret); + + if (TryReadPbkdf2Hash(password, out var iterationCount, out var expectedHash)) + { + var providedHash = HashSecret(passwordBytes, salt, iterationCount); + needsRehash = iterationCount < DefaultIterationCount; + return CryptographicOperations.FixedTimeEquals(providedHash, expectedHash); + } + + var legacyHash = HashLegacySha256(passwordBytes, salt); + needsRehash = CryptographicOperations.FixedTimeEquals(legacyHash, password); + return needsRehash; } /// public byte[] HashSecret(byte[] secret, byte[] salt) { - using var sha256 = SHA256.Create(); - var passwordAndSalt = secret.Concat(salt).ToArray(); - return sha256.ComputeHash(passwordAndSalt); + var hash = HashSecret(secret, salt, DefaultIterationCount); + var encodedHash = Convert.ToBase64String(hash); + return Encoding.UTF8.GetBytes($"{Algorithm}{Separator}{DefaultIterationCount}{Separator}{encodedHash}"); } /// public byte[] GenerateSalt(int saltSize = 32) => RandomNumberGenerator.GetBytes(saltSize); -} \ No newline at end of file + + private static byte[] HashSecret(byte[] secret, byte[] salt, int iterationCount) + { + return Rfc2898DeriveBytes.Pbkdf2(secret, salt, iterationCount, HashAlgorithmName.SHA256, KeySize); + } + + private static byte[] HashLegacySha256(byte[] secret, byte[] salt) + { + return SHA256.HashData(secret.Concat(salt).ToArray()); + } + + private static bool TryReadPbkdf2Hash(byte[] secret, out int iterationCount, out byte[] hash) + { + iterationCount = 0; + hash = []; + + var hashString = Encoding.UTF8.GetString(secret); + var segments = hashString.Split(Separator, 3); + if (segments.Length != 3 || !string.Equals(segments[0], Algorithm, StringComparison.Ordinal)) + return false; + + if (!int.TryParse(segments[1], out iterationCount) || iterationCount <= 0) + return false; + + try + { + hash = Convert.FromBase64String(segments[2]); + return true; + } + catch (FormatException) + { + iterationCount = 0; + hash = []; + return false; + } + } +} diff --git a/src/modules/Elsa.Identity/Services/DefaultUserCredentialsValidator.cs b/src/modules/Elsa.Identity/Services/DefaultUserCredentialsValidator.cs index 21d7fdbff..704e6773b 100644 --- a/src/modules/Elsa.Identity/Services/DefaultUserCredentialsValidator.cs +++ b/src/modules/Elsa.Identity/Services/DefaultUserCredentialsValidator.cs @@ -10,14 +10,16 @@ namespace Elsa.Identity.Services; public class DefaultUserCredentialsValidator : IUserCredentialsValidator { private readonly IUserProvider _userProvider; + private readonly IUserStore _userStore; private readonly ISecretHasher _secretHasher; /// /// Initializes a new instance of the class. /// - public DefaultUserCredentialsValidator(IUserProvider userProvider, ISecretHasher secretHasher) + public DefaultUserCredentialsValidator(IUserProvider userProvider, IUserStore userStore, ISecretHasher secretHasher) { _userProvider = userProvider; + _userStore = userStore; _secretHasher = secretHasher; } @@ -29,8 +31,19 @@ public class DefaultUserCredentialsValidator : IUserCredentialsValidator if (user == null) return null; - var isValidPassword = _secretHasher.VerifySecret(password, user.HashedPassword, user.HashedPasswordSalt); + var isValidPassword = _secretHasher.VerifySecret(password, user.HashedPassword, user.HashedPasswordSalt, out var needsRehash); - return isValidPassword ? user : null; + if (!isValidPassword) + return null; + + if (needsRehash) + { + var hashedPassword = _secretHasher.HashSecret(password); + user.HashedPassword = hashedPassword.EncodeSecret(); + user.HashedPasswordSalt = hashedPassword.EncodeSalt(); + await _userStore.SaveAsync(user, cancellationToken); + } + + return user; } -} \ No newline at end of file +} diff --git a/test/unit/Elsa.Identity.UnitTests/Services/DefaultRandomStringGeneratorTests.cs b/test/unit/Elsa.Identity.UnitTests/Services/DefaultRandomStringGeneratorTests.cs new file mode 100644 index 000000000..23d1f23a3 --- /dev/null +++ b/test/unit/Elsa.Identity.UnitTests/Services/DefaultRandomStringGeneratorTests.cs @@ -0,0 +1,30 @@ +using Elsa.Identity.Services; + +namespace Elsa.Identity.UnitTests.Services; + +public class DefaultRandomStringGeneratorTests +{ + [Fact] + public void Generate_ReturnsRequestedLengthFromAllowedCharacters() + { + var generator = new DefaultRandomStringGenerator(); + + var value = generator.Generate(64, ['a', 'b']); + + Assert.Equal(64, value.Length); + Assert.All(value, x => Assert.True(x is 'a' or 'b')); + } + + [Fact] + public void ApiKeyGenerator_UsesHighEntropyRandomSuffix() + { + var generator = new DefaultApiKeyGeneratorAndParser(); + + var apiKey = generator.Generate("client-1"); + var suffix = apiKey.Split('-', 2)[1]; + var bytes = Convert.FromHexString(suffix); + + Assert.Equal(64, suffix.Length); + Assert.Equal(32, bytes.Length); + } +} diff --git a/test/unit/Elsa.Identity.UnitTests/Services/DefaultSecretHasherTests.cs b/test/unit/Elsa.Identity.UnitTests/Services/DefaultSecretHasherTests.cs new file mode 100644 index 000000000..8a412951a --- /dev/null +++ b/test/unit/Elsa.Identity.UnitTests/Services/DefaultSecretHasherTests.cs @@ -0,0 +1,103 @@ +using System.Security.Cryptography; +using System.Text; +using Elsa.Common.Services; +using Elsa.Identity.Entities; +using Elsa.Identity.Models; +using Elsa.Identity.Providers; +using Elsa.Identity.Services; + +namespace Elsa.Identity.UnitTests.Services; + +public class DefaultSecretHasherTests +{ + private readonly DefaultSecretHasher _hasher = new(); + + [Fact] + public void HashSecret_GeneratesVersionedPbkdf2Hash() + { + var hashedSecret = _hasher.HashSecret("secret"); + + Assert.StartsWith("pbkdf2-sha256$", Encoding.UTF8.GetString(hashedSecret.Secret)); + Assert.True(_hasher.VerifySecret("secret", hashedSecret, out var needsRehash)); + Assert.False(needsRehash); + } + + [Fact] + public void HashSecret_UsesUniqueSalts() + { + var first = _hasher.HashSecret("secret"); + var second = _hasher.HashSecret("secret"); + + Assert.NotEqual(first.EncodeSalt(), second.EncodeSalt()); + Assert.NotEqual(first.EncodeSecret(), second.EncodeSecret()); + } + + [Fact] + public void VerifySecret_AcceptsLegacySha256HashAndRequestsRehash() + { + var hashedSecret = CreateLegacyHash("secret"); + + var verified = _hasher.VerifySecret("secret", hashedSecret, out var needsRehash); + + Assert.True(verified); + Assert.True(needsRehash); + } + + [Fact] + public async Task ValidateAsync_RehashesLegacyUserPassword() + { + var userStore = new MemoryUserStore(new MemoryStore()); + var legacyHash = CreateLegacyHash("secret"); + await userStore.SaveAsync(new User + { + Id = "user-1", + Name = "alice", + HashedPassword = legacyHash.EncodeSecret(), + HashedPasswordSalt = legacyHash.EncodeSalt() + }); + var validator = new DefaultUserCredentialsValidator(new StoreBasedUserProvider(userStore), userStore, _hasher); + + var user = await validator.ValidateAsync("alice", "secret"); + var reloadedUser = await userStore.FindAsync(new UserFilter { Name = "alice" }); + + Assert.NotNull(user); + Assert.NotNull(reloadedUser); + Assert.StartsWith("pbkdf2-sha256$", Encoding.UTF8.GetString(Convert.FromBase64String(reloadedUser.HashedPassword))); + } + + [Fact] + public async Task ValidateAsync_RehashesLegacyApplicationApiKey() + { + var apiKeyGenerator = new DefaultApiKeyGeneratorAndParser(); + var apiKey = apiKeyGenerator.Generate("client-1"); + var applicationStore = new MemoryApplicationStore(new MemoryStore()); + var legacyHash = CreateLegacyHash(apiKey); + await applicationStore.SaveAsync(new Application + { + Id = "app-1", + ClientId = "client-1", + Name = "Client 1", + HashedApiKey = legacyHash.EncodeSecret(), + HashedApiKeySalt = legacyHash.EncodeSalt(), + HashedClientSecret = "", + HashedClientSecretSalt = "" + }); + var applicationProvider = new StoreBasedApplicationProvider(applicationStore); + var validator = new DefaultApplicationCredentialsValidator(apiKeyGenerator, applicationProvider, applicationStore, _hasher); + + var application = await validator.ValidateAsync(apiKey); + var reloadedApplication = await applicationStore.FindAsync(new ApplicationFilter { ClientId = "client-1" }); + + Assert.NotNull(application); + Assert.NotNull(reloadedApplication); + Assert.StartsWith("pbkdf2-sha256$", Encoding.UTF8.GetString(Convert.FromBase64String(reloadedApplication.HashedApiKey))); + } + + private static HashedSecret CreateLegacyHash(string secret) + { + var salt = RandomNumberGenerator.GetBytes(32); + var secretBytes = Encoding.UTF8.GetBytes(secret); + var hash = SHA256.HashData(secretBytes.Concat(salt).ToArray()); + return HashedSecret.FromBytes(hash, salt); + } +}