Harden identity secret generation and hashing

This commit is contained in:
Sipke Schoorstra 2026-05-20 13:20:46 +02:00
parent e7b1f8055b
commit 304e990319
No known key found for this signature in database
GPG key ID: 5C10502B28A4268F
10 changed files with 270 additions and 30 deletions

View file

@ -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))
* 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))

View file

@ -39,6 +39,16 @@ public interface ISecretHasher
/// <param name="salt">The salt.</param>
/// <returns>True if the secret is valid, otherwise false.</returns>
bool VerifySecret(string clearTextSecret, string secret, string salt);
/// <summary>
/// Verifies the secret.
/// </summary>
/// <param name="clearTextSecret">The secret to verify.</param>
/// <param name="secret">The hashed secret.</param>
/// <param name="salt">The salt.</param>
/// <param name="needsRehash">Whether the stored hash should be upgraded.</param>
/// <returns>True if the secret is valid, otherwise false.</returns>
bool VerifySecret(string clearTextSecret, string secret, string salt, out bool needsRehash);
/// <summary>
/// Verifies the secret.
@ -48,10 +58,19 @@ public interface ISecretHasher
/// <returns>True if the secret is valid, otherwise false.</returns>
bool VerifySecret(string clearTextSecret, HashedSecret hashedSecret);
/// <summary>
/// Verifies the secret.
/// </summary>
/// <param name="clearTextSecret">The secret to verify.</param>
/// <param name="hashedSecret">The hashed secret.</param>
/// <param name="needsRehash">Whether the stored hash should be upgraded.</param>
/// <returns>True if the secret is valid, otherwise false.</returns>
bool VerifySecret(string clearTextSecret, HashedSecret hashedSecret, out bool needsRehash);
/// <summary>
/// Generates a salt.
/// </summary>
/// <param name="saltSize">The size of the salt.</param>
/// <returns>The salt.</returns>
byte[] GenerateSalt(int saltSize = 32) => RandomNumberGenerator.GetBytes(saltSize);
}
}

View file

@ -78,3 +78,6 @@ identity.UseDefaultAdmin("admin", "password", "admin", new List<string> { "*" })
- 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.

View file

@ -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;
}
}
}

View file

@ -11,15 +11,17 @@ public class DefaultApplicationCredentialsValidator : IApplicationCredentialsVal
{
private readonly IApiKeyParser _apiKeyParser;
private readonly IApplicationProvider _applicationProvider;
private readonly IApplicationStore _applicationStore;
private readonly ISecretHasher _secretHasher;
/// <summary>
/// Initializes a new instance of the <see cref="DefaultApplicationCredentialsValidator"/> class.
/// </summary>
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;
}
}
}

View file

@ -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;
/// <inheritdoc />
public class DefaultRandomStringGenerator : IRandomStringGenerator
{
private readonly Random _random;
/// <summary>
/// Initializes a new instance of the <see cref="DefaultRandomStringGenerator"/> class.
/// </summary>
public DefaultRandomStringGenerator()
{
_random = new Random();
}
/// <inheritdoc />
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();
}
}
}

View file

@ -8,6 +8,11 @@ namespace Elsa.Identity.Services;
/// <inheritdoc />
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;
/// <inheritdoc />
public HashedSecret HashSecret(string secret)
{
@ -30,23 +35,82 @@ public class DefaultSecretHasher : ISecretHasher
return VerifySecret(clearTextSecret, hashedPassword);
}
/// <inheritdoc />
public bool VerifySecret(string clearTextSecret, string secret, string salt, out bool needsRehash)
{
var hashedPassword = HashedSecret.FromString(secret, salt);
return VerifySecret(clearTextSecret, hashedPassword, out needsRehash);
}
/// <inheritdoc />
public bool VerifySecret(string clearTextSecret, HashedSecret hashedSecret)
{
return VerifySecret(clearTextSecret, hashedSecret, out _);
}
/// <inheritdoc />
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;
}
/// <inheritdoc />
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}");
}
/// <inheritdoc />
public byte[] GenerateSalt(int saltSize = 32) => RandomNumberGenerator.GetBytes(saltSize);
}
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;
}
}
}

View file

@ -10,14 +10,16 @@ namespace Elsa.Identity.Services;
public class DefaultUserCredentialsValidator : IUserCredentialsValidator
{
private readonly IUserProvider _userProvider;
private readonly IUserStore _userStore;
private readonly ISecretHasher _secretHasher;
/// <summary>
/// Initializes a new instance of the <see cref="DefaultUserCredentialsValidator"/> class.
/// </summary>
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;
}
}
}

View file

@ -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);
}
}

View file

@ -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<User>());
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<Application>());
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);
}
}