diff --git a/doc/changelogs/3.6.0.md b/doc/changelogs/3.6.0.md index fa9fcaa19..aa47e6e5c 100644 --- a/doc/changelogs/3.6.0.md +++ b/doc/changelogs/3.6.0.md @@ -6,7 +6,7 @@ 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. +- **Identity secret hashing hardened**: New user passwords, client secrets, and API keys are hashed with PBKDF2-SHA256 using 600,000 iterations, 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. diff --git a/src/modules/Elsa.Identity/Contracts/ISecretHasher.cs b/src/modules/Elsa.Identity/Contracts/ISecretHasher.cs index a51323f54..a0d429e9c 100644 --- a/src/modules/Elsa.Identity/Contracts/ISecretHasher.cs +++ b/src/modules/Elsa.Identity/Contracts/ISecretHasher.cs @@ -48,7 +48,11 @@ public interface ISecretHasher /// 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); + bool VerifySecret(string clearTextSecret, string secret, string salt, out bool needsRehash) + { + needsRehash = false; + return VerifySecret(clearTextSecret, secret, salt); + } /// /// Verifies the secret. @@ -65,7 +69,11 @@ public interface ISecretHasher /// 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); + bool VerifySecret(string clearTextSecret, HashedSecret hashedSecret, out bool needsRehash) + { + needsRehash = false; + return VerifySecret(clearTextSecret, hashedSecret); + } /// /// Generates a salt. diff --git a/src/modules/Elsa.Identity/README.md b/src/modules/Elsa.Identity/README.md index 343eedacb..1df93648c 100644 --- a/src/modules/Elsa.Identity/README.md +++ b/src/modules/Elsa.Identity/README.md @@ -80,4 +80,4 @@ identity.UseDefaultAdmin("admin", "password", "admin", new List { "*" }) ## 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. +New identity passwords, client secrets, and API keys are hashed with PBKDF2-SHA256 using 600,000 iterations, 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/DefaultSecretHasher.cs b/src/modules/Elsa.Identity/Services/DefaultSecretHasher.cs index 23f3f00c6..46a4dec67 100644 --- a/src/modules/Elsa.Identity/Services/DefaultSecretHasher.cs +++ b/src/modules/Elsa.Identity/Services/DefaultSecretHasher.cs @@ -10,7 +10,7 @@ public class DefaultSecretHasher : ISecretHasher { private const string Algorithm = "pbkdf2-sha256"; private const char Separator = '$'; - private const int DefaultIterationCount = 210_000; + private const int DefaultIterationCount = 600_000; private const int MaxIterationCount = DefaultIterationCount * 4; private const int KeySize = 32; diff --git a/test/unit/Elsa.Identity.UnitTests/Services/DefaultSecretHasherTests.cs b/test/unit/Elsa.Identity.UnitTests/Services/DefaultSecretHasherTests.cs index 5ff552227..7aabeebea 100644 --- a/test/unit/Elsa.Identity.UnitTests/Services/DefaultSecretHasherTests.cs +++ b/test/unit/Elsa.Identity.UnitTests/Services/DefaultSecretHasherTests.cs @@ -17,7 +17,7 @@ public class DefaultSecretHasherTests { var hashedSecret = _hasher.HashSecret("secret"); - Assert.StartsWith("pbkdf2-sha256$", Encoding.UTF8.GetString(hashedSecret.Secret)); + Assert.StartsWith("pbkdf2-sha256$600000$", Encoding.UTF8.GetString(hashedSecret.Secret)); Assert.True(_hasher.VerifySecret("secret", hashedSecret, out var needsRehash)); Assert.False(needsRehash); } diff --git a/test/unit/Elsa.Identity.UnitTests/Services/ISecretHasherTests.cs b/test/unit/Elsa.Identity.UnitTests/Services/ISecretHasherTests.cs new file mode 100644 index 000000000..a6d272219 --- /dev/null +++ b/test/unit/Elsa.Identity.UnitTests/Services/ISecretHasherTests.cs @@ -0,0 +1,49 @@ +using System.Text; +using Elsa.Identity.Contracts; +using Elsa.Identity.Models; + +namespace Elsa.Identity.UnitTests.Services; + +public class ISecretHasherTests +{ + private readonly ISecretHasher _hasher = new BackwardCompatibleSecretHasher(); + + [Fact] + public void VerifySecret_WithStringSaltAndNeedsRehash_DelegatesToExistingImplementation() + { + var isVerified = _hasher.VerifySecret("secret", "secret", "salt", out var needsRehash); + + Assert.True(isVerified); + Assert.False(needsRehash); + } + + [Fact] + public void VerifySecret_WithHashedSecretAndNeedsRehash_DelegatesToExistingImplementation() + { + var hashedSecret = HashedSecret.FromBytes(Encoding.UTF8.GetBytes("secret"), Encoding.UTF8.GetBytes("salt")); + + var isVerified = _hasher.VerifySecret("secret", hashedSecret, out var needsRehash); + + Assert.True(isVerified); + Assert.False(needsRehash); + } + + private sealed class BackwardCompatibleSecretHasher : ISecretHasher + { + public HashedSecret HashSecret(string secret) => throw new NotSupportedException(); + + public HashedSecret HashSecret(string secret, byte[] salt) => throw new NotSupportedException(); + + public byte[] HashSecret(byte[] secret, byte[] salt) => throw new NotSupportedException(); + + public bool VerifySecret(string clearTextSecret, string secret, string salt) + { + return clearTextSecret == secret && salt == "salt"; + } + + public bool VerifySecret(string clearTextSecret, HashedSecret hashedSecret) + { + return clearTextSecret == Encoding.UTF8.GetString(hashedSecret.Secret); + } + } +}