From fcb1ad1534ddb52a215d3f47607f85cefb2641cf Mon Sep 17 00:00:00 2001 From: Raphael Freitas Date: Wed, 1 Apr 2026 17:19:45 -0300 Subject: [PATCH 1/2] feat: adiciona pacotes Codout.Security (Core, Argon2, BCrypt, Scrypt) --- Codout.Framework.sln | 30 ++++ .../Argon2Extensions.cs | 20 +++ .../Codout.Security.Argon2/Argon2Options.cs | 17 ++ .../ArgonPasswordHash.cs | 89 +++++++++++ .../Codout.Security.Argon2.csproj | 18 +++ .../BcryptExtensions.cs | 20 +++ .../Codout.Security.Bcrypt/BcryptOptions.cs | 16 ++ .../BcryptPasswordHash.cs | 45 ++++++ .../BcryptSaltRevision.cs | 10 ++ .../Codout.Security.Bcrypt.csproj | 19 +++ .../Codout.Security.Core.csproj | 13 ++ .../IPasswordHashBuilder.cs | 11 ++ .../Codout.Security.Core/IPasswordHasher.cs | 10 ++ .../ImprovedPasswordHasherOptions.cs | 9 ++ .../PasswordHasherBuilder.cs | 19 +++ .../PasswordHasherServiceExtensions.cs | 18 +++ .../PasswordHasherStrength.cs | 11 ++ .../PasswordVerificationResult.cs | 8 + .../Codout.Security.Scrypt.csproj | 19 +++ .../ScryptExtensions.cs | 20 +++ .../Codout.Security.Scrypt/ScryptOptions.cs | 16 ++ .../ScryptPasswordHash.cs | 94 +++++++++++ src/Security/README.md | 146 ++++++++++++++++++ 23 files changed, 678 insertions(+) create mode 100644 src/Security/Codout.Security.Argon2/Argon2Extensions.cs create mode 100644 src/Security/Codout.Security.Argon2/Argon2Options.cs create mode 100644 src/Security/Codout.Security.Argon2/ArgonPasswordHash.cs create mode 100644 src/Security/Codout.Security.Argon2/Codout.Security.Argon2.csproj create mode 100644 src/Security/Codout.Security.Bcrypt/BcryptExtensions.cs create mode 100644 src/Security/Codout.Security.Bcrypt/BcryptOptions.cs create mode 100644 src/Security/Codout.Security.Bcrypt/BcryptPasswordHash.cs create mode 100644 src/Security/Codout.Security.Bcrypt/BcryptSaltRevision.cs create mode 100644 src/Security/Codout.Security.Bcrypt/Codout.Security.Bcrypt.csproj create mode 100644 src/Security/Codout.Security.Core/Codout.Security.Core.csproj create mode 100644 src/Security/Codout.Security.Core/IPasswordHashBuilder.cs create mode 100644 src/Security/Codout.Security.Core/IPasswordHasher.cs create mode 100644 src/Security/Codout.Security.Core/ImprovedPasswordHasherOptions.cs create mode 100644 src/Security/Codout.Security.Core/PasswordHasherBuilder.cs create mode 100644 src/Security/Codout.Security.Core/PasswordHasherServiceExtensions.cs create mode 100644 src/Security/Codout.Security.Core/PasswordHasherStrength.cs create mode 100644 src/Security/Codout.Security.Core/PasswordVerificationResult.cs create mode 100644 src/Security/Codout.Security.Scrypt/Codout.Security.Scrypt.csproj create mode 100644 src/Security/Codout.Security.Scrypt/ScryptExtensions.cs create mode 100644 src/Security/Codout.Security.Scrypt/ScryptOptions.cs create mode 100644 src/Security/Codout.Security.Scrypt/ScryptPasswordHash.cs create mode 100644 src/Security/README.md diff --git a/Codout.Framework.sln b/Codout.Framework.sln index 7bd7290..4ef8528 100644 --- a/Codout.Framework.sln +++ b/Codout.Framework.sln @@ -58,6 +58,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Codout.Framework.Data", "Co EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Codout.Mailer.Razor", "Codout.Mailer.Razor\Codout.Mailer.Razor.csproj", "{B42C234C-CEB5-76D7-752D-DE71B2F1E9F7}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Security", "Security", "{F65D869E-54A1-41D2-A6C3-EAD78678ADC4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Codout.Security.Core", "src\Security\Codout.Security.Core\Codout.Security.Core.csproj", "{6764EB74-2D58-45E8-8F5B-942B4FB1C47D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Codout.Security.Argon2", "src\Security\Codout.Security.Argon2\Codout.Security.Argon2.csproj", "{3D7FBB69-1515-4A0E-BAED-D87C08C921EA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Codout.Security.Bcrypt", "src\Security\Codout.Security.Bcrypt\Codout.Security.Bcrypt.csproj", "{732C166B-24BF-4A84-8C5F-4EDB6340CE45}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Codout.Security.Scrypt", "src\Security\Codout.Security.Scrypt\Codout.Security.Scrypt.csproj", "{EF60A915-F3C8-4ABE-A6B3-5086F7093FB6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -136,6 +146,22 @@ Global {B42C234C-CEB5-76D7-752D-DE71B2F1E9F7}.Debug|Any CPU.Build.0 = Debug|Any CPU {B42C234C-CEB5-76D7-752D-DE71B2F1E9F7}.Release|Any CPU.ActiveCfg = Release|Any CPU {B42C234C-CEB5-76D7-752D-DE71B2F1E9F7}.Release|Any CPU.Build.0 = Release|Any CPU + {6764EB74-2D58-45E8-8F5B-942B4FB1C47D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6764EB74-2D58-45E8-8F5B-942B4FB1C47D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6764EB74-2D58-45E8-8F5B-942B4FB1C47D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6764EB74-2D58-45E8-8F5B-942B4FB1C47D}.Release|Any CPU.Build.0 = Release|Any CPU + {3D7FBB69-1515-4A0E-BAED-D87C08C921EA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3D7FBB69-1515-4A0E-BAED-D87C08C921EA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3D7FBB69-1515-4A0E-BAED-D87C08C921EA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3D7FBB69-1515-4A0E-BAED-D87C08C921EA}.Release|Any CPU.Build.0 = Release|Any CPU + {732C166B-24BF-4A84-8C5F-4EDB6340CE45}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {732C166B-24BF-4A84-8C5F-4EDB6340CE45}.Debug|Any CPU.Build.0 = Debug|Any CPU + {732C166B-24BF-4A84-8C5F-4EDB6340CE45}.Release|Any CPU.ActiveCfg = Release|Any CPU + {732C166B-24BF-4A84-8C5F-4EDB6340CE45}.Release|Any CPU.Build.0 = Release|Any CPU + {EF60A915-F3C8-4ABE-A6B3-5086F7093FB6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EF60A915-F3C8-4ABE-A6B3-5086F7093FB6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EF60A915-F3C8-4ABE-A6B3-5086F7093FB6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EF60A915-F3C8-4ABE-A6B3-5086F7093FB6}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -161,6 +187,10 @@ Global {7A5F8194-FBD0-47C0-A8F8-1EB68662BE1E} = {B6832CA4-604B-4DC8-908B-06405133B6B6} {52DE6A78-4383-E3EC-3109-2410414F8EEE} = {B6832CA4-604B-4DC8-908B-06405133B6B6} {B42C234C-CEB5-76D7-752D-DE71B2F1E9F7} = {992EAEDC-B75F-45CA-8B2C-840E3F9B5133} + {6764EB74-2D58-45E8-8F5B-942B4FB1C47D} = {F65D869E-54A1-41D2-A6C3-EAD78678ADC4} + {3D7FBB69-1515-4A0E-BAED-D87C08C921EA} = {F65D869E-54A1-41D2-A6C3-EAD78678ADC4} + {732C166B-24BF-4A84-8C5F-4EDB6340CE45} = {F65D869E-54A1-41D2-A6C3-EAD78678ADC4} + {EF60A915-F3C8-4ABE-A6B3-5086F7093FB6} = {F65D869E-54A1-41D2-A6C3-EAD78678ADC4} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {F077B358-8F0D-4791-8E85-DDED3550C27A} diff --git a/src/Security/Codout.Security.Argon2/Argon2Extensions.cs b/src/Security/Codout.Security.Argon2/Argon2Extensions.cs new file mode 100644 index 0000000..f49da25 --- /dev/null +++ b/src/Security/Codout.Security.Argon2/Argon2Extensions.cs @@ -0,0 +1,20 @@ +using Codout.Security.Core; +using Microsoft.Extensions.DependencyInjection; + +namespace Codout.Security.Argon2; + +public static class Argon2Extensions +{ + public static IServiceCollection UseArgon2(this IPasswordHashBuilder builder, Action? configure = null) + { + builder.Services.Configure(options => + { + options.Strength = builder.Options.Strength; + }); + + if (configure != null) + builder.Services.Configure(configure); + + return builder.Services.AddScoped(); + } +} \ No newline at end of file diff --git a/src/Security/Codout.Security.Argon2/Argon2Options.cs b/src/Security/Codout.Security.Argon2/Argon2Options.cs new file mode 100644 index 0000000..f3c0452 --- /dev/null +++ b/src/Security/Codout.Security.Argon2/Argon2Options.cs @@ -0,0 +1,17 @@ +namespace Codout.Security.Argon2; + +public class Argon2Options +{ + /// + /// opslimit represents a maximum amount of computations to perform. + /// Raising this number will make the function require more CPU cycles to compute a key. + /// When set along with MemLimit, overrides the Strength setting. + /// + public long? OpsLimit { get; set; } + + /// + /// memlimit is the maximum amount of RAM that the function will use, in bytes. + /// When set along with OpsLimit, overrides the Strength setting. + /// + public int? MemLimit { get; set; } +} diff --git a/src/Security/Codout.Security.Argon2/ArgonPasswordHash.cs b/src/Security/Codout.Security.Argon2/ArgonPasswordHash.cs new file mode 100644 index 0000000..5274bc3 --- /dev/null +++ b/src/Security/Codout.Security.Argon2/ArgonPasswordHash.cs @@ -0,0 +1,89 @@ +using Codout.Security.Core; +using Microsoft.Extensions.Options; +using Sodium; + +namespace Codout.Security.Argon2 +{ + public class ArgonPasswordHash( + IOptions? optionsAccessor = null, + IOptions? argon2OptionsAccessor = null) : IPasswordHasher + { + private readonly ImprovedPasswordHasherOptions _options = optionsAccessor?.Value ?? new ImprovedPasswordHasherOptions(); + private readonly Argon2Options _argon2Options = argon2OptionsAccessor?.Value ?? new Argon2Options(); + + public string HashPassword(string password) + { + if (string.IsNullOrEmpty(password)) + throw new ArgumentNullException(nameof(password), $"{nameof(password)} should not be null"); + + if (_argon2Options.OpsLimit.HasValue && _argon2Options.MemLimit.HasValue) + return PasswordHash.ArgonHashString(password, _argon2Options.OpsLimit.Value, _argon2Options.MemLimit.Value).TrimEnd('\0'); + + return _options.Strength switch + { + PasswordHasherStrength.Interactive => PasswordHash.ArgonHashString(password).TrimEnd('\0'), + PasswordHasherStrength.Moderate => PasswordHash.ArgonHashString(password, PasswordHash.StrengthArgon.Moderate).TrimEnd('\0'), + PasswordHasherStrength.Sensitive => PasswordHash.ArgonHashString(password, PasswordHash.StrengthArgon.Sensitive).TrimEnd('\0'), + _ => throw new ArgumentOutOfRangeException() + }; + } + + public PasswordVerificationResult VerifyHashedPassword(string hashedPassword, string providedPassword) + { + if (string.IsNullOrEmpty(hashedPassword)) + throw new ArgumentNullException(nameof(hashedPassword), $"{nameof(hashedPassword)} should not be null"); + + if (string.IsNullOrEmpty(providedPassword)) + throw new ArgumentNullException(nameof(providedPassword), $"{nameof(providedPassword)} should not be null"); + + if (!PasswordHash.ArgonHashStringVerify(hashedPassword, providedPassword)) + return PasswordVerificationResult.Failed; + + return NeedsRehash(hashedPassword) + ? PasswordVerificationResult.SuccessRehashNeeded + : PasswordVerificationResult.Success; + } + + private bool NeedsRehash(string hashedPassword) + { + // Parse Argon2 hash format: $argon2id$v=19$m=65536,t=3,p=1$salt$hash + var parts = hashedPassword.Split('$'); + if (parts.Length < 4) + return false; + + var paramPart = parts[3]; + var parameters = paramPart.Split(','); + + long storedMemKib = 0; + long storedTimeCost = 0; + + foreach (var param in parameters) + { + if (param.StartsWith("m=") && long.TryParse(param.AsSpan(2), out var m)) + storedMemKib = m; + else if (param.StartsWith("t=") && long.TryParse(param.AsSpan(2), out var t)) + storedTimeCost = t; + } + + if (storedMemKib == 0 || storedTimeCost == 0) + return false; + + var (expectedTimeCost, expectedMemKib) = GetExpectedParameters(); + return storedTimeCost < expectedTimeCost || storedMemKib < expectedMemKib; + } + + private (long timeCost, long memKib) GetExpectedParameters() + { + if (_argon2Options.OpsLimit.HasValue && _argon2Options.MemLimit.HasValue) + return (_argon2Options.OpsLimit.Value, _argon2Options.MemLimit.Value / 1024); + + return _options.Strength switch + { + PasswordHasherStrength.Interactive => (2, 65536), // 64 MiB + PasswordHasherStrength.Moderate => (3, 262144), // 256 MiB + PasswordHasherStrength.Sensitive => (4, 1048576), // 1 GiB + _ => (4, 1048576) + }; + } + } +} diff --git a/src/Security/Codout.Security.Argon2/Codout.Security.Argon2.csproj b/src/Security/Codout.Security.Argon2/Codout.Security.Argon2.csproj new file mode 100644 index 0000000..3ba324a --- /dev/null +++ b/src/Security/Codout.Security.Argon2/Codout.Security.Argon2.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + diff --git a/src/Security/Codout.Security.Bcrypt/BcryptExtensions.cs b/src/Security/Codout.Security.Bcrypt/BcryptExtensions.cs new file mode 100644 index 0000000..cc20e89 --- /dev/null +++ b/src/Security/Codout.Security.Bcrypt/BcryptExtensions.cs @@ -0,0 +1,20 @@ +using Codout.Security.Core; +using Microsoft.Extensions.DependencyInjection; + +namespace Codout.Security.Bcrypt; + +public static class BcryptExtensions +{ + public static IServiceCollection UseBcrypt(this IPasswordHashBuilder builder, Action? configure = null) + { + builder.Services.Configure(options => + { + options.Strength = builder.Options.Strength; + }); + + if (configure != null) + builder.Services.Configure(configure); + + return builder.Services.AddScoped(); + } +} \ No newline at end of file diff --git a/src/Security/Codout.Security.Bcrypt/BcryptOptions.cs b/src/Security/Codout.Security.Bcrypt/BcryptOptions.cs new file mode 100644 index 0000000..281503e --- /dev/null +++ b/src/Security/Codout.Security.Bcrypt/BcryptOptions.cs @@ -0,0 +1,16 @@ +namespace Codout.Security.Bcrypt; + +public class BcryptOptions +{ + /// + /// The log2 of the number of rounds of hashing to apply on BCrypt. + /// The work factor therefore increases as 2**workFactor. + /// Valid range: 4-31. + /// + public int WorkFactor { get; set; } = 12; + + /// + /// The salt revision to use for BCrypt hashing. + /// + public BcryptSaltRevision SaltRevision { get; set; } = BcryptSaltRevision.Revision2B; +} diff --git a/src/Security/Codout.Security.Bcrypt/BcryptPasswordHash.cs b/src/Security/Codout.Security.Bcrypt/BcryptPasswordHash.cs new file mode 100644 index 0000000..fe12a5d --- /dev/null +++ b/src/Security/Codout.Security.Bcrypt/BcryptPasswordHash.cs @@ -0,0 +1,45 @@ +using Codout.Security.Core; +using Microsoft.Extensions.Options; + +namespace Codout.Security.Bcrypt +{ + public class BcryptPasswordHash(IOptions? bcryptOptionsAccessor = null) : IPasswordHasher + { + private readonly BcryptOptions _bcryptOptions = bcryptOptionsAccessor?.Value ?? new BcryptOptions(); + + public string HashPassword(string password) + { + if (string.IsNullOrEmpty(password)) + throw new ArgumentNullException(nameof(password), $"{nameof(password)} should not be null"); + + var salt = BCrypt.Net.BCrypt.GenerateSalt(_bcryptOptions.WorkFactor, GetSaltRevision()); + return BCrypt.Net.BCrypt.HashPassword(password, salt); + } + + public PasswordVerificationResult VerifyHashedPassword(string hashedPassword, string providedPassword) + { + if (string.IsNullOrEmpty(hashedPassword)) + throw new ArgumentNullException(nameof(hashedPassword), $"{nameof(hashedPassword)} should not be null"); + + if (string.IsNullOrEmpty(providedPassword)) + throw new ArgumentNullException(nameof(providedPassword), $"{nameof(providedPassword)} should not be null"); + + if (!BCrypt.Net.BCrypt.Verify(providedPassword, hashedPassword)) + return PasswordVerificationResult.Failed; + + return BCrypt.Net.BCrypt.PasswordNeedsRehash(hashedPassword, _bcryptOptions.WorkFactor) + ? PasswordVerificationResult.SuccessRehashNeeded + : PasswordVerificationResult.Success; + } + + private char GetSaltRevision() => _bcryptOptions.SaltRevision switch + { + BcryptSaltRevision.Revision2 => 'a', + BcryptSaltRevision.Revision2A => 'a', + BcryptSaltRevision.Revision2B => 'b', + BcryptSaltRevision.Revision2X => 'x', + BcryptSaltRevision.Revision2Y => 'y', + _ => throw new ArgumentOutOfRangeException(nameof(_bcryptOptions.SaltRevision)) + }; + } +} diff --git a/src/Security/Codout.Security.Bcrypt/BcryptSaltRevision.cs b/src/Security/Codout.Security.Bcrypt/BcryptSaltRevision.cs new file mode 100644 index 0000000..df74353 --- /dev/null +++ b/src/Security/Codout.Security.Bcrypt/BcryptSaltRevision.cs @@ -0,0 +1,10 @@ +namespace Codout.Security.Bcrypt; + +public enum BcryptSaltRevision +{ + Revision2, + Revision2A, + Revision2B, + Revision2X, + Revision2Y +} diff --git a/src/Security/Codout.Security.Bcrypt/Codout.Security.Bcrypt.csproj b/src/Security/Codout.Security.Bcrypt/Codout.Security.Bcrypt.csproj new file mode 100644 index 0000000..521c74e --- /dev/null +++ b/src/Security/Codout.Security.Bcrypt/Codout.Security.Bcrypt.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + diff --git a/src/Security/Codout.Security.Core/Codout.Security.Core.csproj b/src/Security/Codout.Security.Core/Codout.Security.Core.csproj new file mode 100644 index 0000000..5831e53 --- /dev/null +++ b/src/Security/Codout.Security.Core/Codout.Security.Core.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/src/Security/Codout.Security.Core/IPasswordHashBuilder.cs b/src/Security/Codout.Security.Core/IPasswordHashBuilder.cs new file mode 100644 index 0000000..6bad9b1 --- /dev/null +++ b/src/Security/Codout.Security.Core/IPasswordHashBuilder.cs @@ -0,0 +1,11 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Codout.Security.Core; + +public interface IPasswordHashBuilder +{ + IServiceCollection Services { get; } + ImprovedPasswordHasherOptions Options { get; } + + IPasswordHashBuilder WithStrength(PasswordHasherStrength strength); +} \ No newline at end of file diff --git a/src/Security/Codout.Security.Core/IPasswordHasher.cs b/src/Security/Codout.Security.Core/IPasswordHasher.cs new file mode 100644 index 0000000..45930c9 --- /dev/null +++ b/src/Security/Codout.Security.Core/IPasswordHasher.cs @@ -0,0 +1,10 @@ +namespace Codout.Security.Core; + +public interface IPasswordHasher +{ + string HashPassword(string password); + + PasswordVerificationResult VerifyHashedPassword( + string hashedPassword, + string providedPassword); +} \ No newline at end of file diff --git a/src/Security/Codout.Security.Core/ImprovedPasswordHasherOptions.cs b/src/Security/Codout.Security.Core/ImprovedPasswordHasherOptions.cs new file mode 100644 index 0000000..b57229d --- /dev/null +++ b/src/Security/Codout.Security.Core/ImprovedPasswordHasherOptions.cs @@ -0,0 +1,9 @@ +namespace Codout.Security.Core; + +public class ImprovedPasswordHasherOptions +{ + /// + /// Password Strength. Used to configure the hashing algorithm's resource usage. + /// + public PasswordHasherStrength Strength { get; set; } = PasswordHasherStrength.Sensitive; +} \ No newline at end of file diff --git a/src/Security/Codout.Security.Core/PasswordHasherBuilder.cs b/src/Security/Codout.Security.Core/PasswordHasherBuilder.cs new file mode 100644 index 0000000..2df69f8 --- /dev/null +++ b/src/Security/Codout.Security.Core/PasswordHasherBuilder.cs @@ -0,0 +1,19 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Codout.Security.Core; + +public class PasswordHasherBuilder(IServiceCollection services) : IPasswordHashBuilder +{ + public ImprovedPasswordHasherOptions Options { get; } = new(); + + public IServiceCollection Services { get; } = services ?? throw new ArgumentNullException(nameof(services)); + + /// + /// Password Strength. Used to configure the hashing algorithm's resource usage. + /// + public IPasswordHashBuilder WithStrength(PasswordHasherStrength strength) + { + Options.Strength = strength; + return this; + } +} \ No newline at end of file diff --git a/src/Security/Codout.Security.Core/PasswordHasherServiceExtensions.cs b/src/Security/Codout.Security.Core/PasswordHasherServiceExtensions.cs new file mode 100644 index 0000000..ebbb0f4 --- /dev/null +++ b/src/Security/Codout.Security.Core/PasswordHasherServiceExtensions.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Codout.Security.Core +{ + public static class PasswordHasherServiceExtensions + { + public static IPasswordHashBuilder UseCustomHashPasswordBuilder(this IServiceCollection services) + { + return new PasswordHasherBuilder(services); + } + + public static IPasswordHashBuilder UpgradePasswordSecurity(this IServiceCollection services) + { + return services.UseCustomHashPasswordBuilder(); + } + + } +} diff --git a/src/Security/Codout.Security.Core/PasswordHasherStrength.cs b/src/Security/Codout.Security.Core/PasswordHasherStrength.cs new file mode 100644 index 0000000..0f9659e --- /dev/null +++ b/src/Security/Codout.Security.Core/PasswordHasherStrength.cs @@ -0,0 +1,11 @@ +namespace Codout.Security.Core; + +public enum PasswordHasherStrength +{ + /// For interactive sessions (fast: uses 16MB of RAM). + Interactive, + /// For normal use (moderate: uses 128MB of RAM). + Moderate, + /// For highly sensitive data (slow: uses more than 1GB of RAM). + Sensitive +} \ No newline at end of file diff --git a/src/Security/Codout.Security.Core/PasswordVerificationResult.cs b/src/Security/Codout.Security.Core/PasswordVerificationResult.cs new file mode 100644 index 0000000..6b2d0cc --- /dev/null +++ b/src/Security/Codout.Security.Core/PasswordVerificationResult.cs @@ -0,0 +1,8 @@ +namespace Codout.Security.Core; + +public enum PasswordVerificationResult +{ + Failed, + Success, + SuccessRehashNeeded +} \ No newline at end of file diff --git a/src/Security/Codout.Security.Scrypt/Codout.Security.Scrypt.csproj b/src/Security/Codout.Security.Scrypt/Codout.Security.Scrypt.csproj new file mode 100644 index 0000000..26f553a --- /dev/null +++ b/src/Security/Codout.Security.Scrypt/Codout.Security.Scrypt.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + diff --git a/src/Security/Codout.Security.Scrypt/ScryptExtensions.cs b/src/Security/Codout.Security.Scrypt/ScryptExtensions.cs new file mode 100644 index 0000000..2c78aae --- /dev/null +++ b/src/Security/Codout.Security.Scrypt/ScryptExtensions.cs @@ -0,0 +1,20 @@ +using Codout.Security.Core; +using Microsoft.Extensions.DependencyInjection; + +namespace Codout.Security.Scrypt; + +public static class ScryptExtensions +{ + public static IServiceCollection UseScrypt(this IPasswordHashBuilder builder, Action? configure = null) + { + builder.Services.Configure(options => + { + options.Strength = builder.Options.Strength; + }); + + if (configure != null) + builder.Services.Configure(configure); + + return builder.Services.AddScoped(); + } +} \ No newline at end of file diff --git a/src/Security/Codout.Security.Scrypt/ScryptOptions.cs b/src/Security/Codout.Security.Scrypt/ScryptOptions.cs new file mode 100644 index 0000000..efafe77 --- /dev/null +++ b/src/Security/Codout.Security.Scrypt/ScryptOptions.cs @@ -0,0 +1,16 @@ +namespace Codout.Security.Scrypt; + +public class ScryptOptions +{ + /// + /// opslimit represents a maximum amount of computations to perform. + /// When set along with MemLimit, overrides the Strength setting. + /// + public long? OpsLimit { get; set; } + + /// + /// memlimit is the maximum amount of RAM that the function will use, in bytes. + /// When set along with OpsLimit, overrides the Strength setting. + /// + public int? MemLimit { get; set; } +} diff --git a/src/Security/Codout.Security.Scrypt/ScryptPasswordHash.cs b/src/Security/Codout.Security.Scrypt/ScryptPasswordHash.cs new file mode 100644 index 0000000..08f9c40 --- /dev/null +++ b/src/Security/Codout.Security.Scrypt/ScryptPasswordHash.cs @@ -0,0 +1,94 @@ +using Codout.Security.Core; +using Microsoft.Extensions.Options; +using Sodium; + +namespace Codout.Security.Scrypt +{ + public class ScryptPasswordHash( + IOptions? optionsAccessor = null, + IOptions? scryptOptionsAccessor = null) : IPasswordHasher + { + private readonly ImprovedPasswordHasherOptions _options = optionsAccessor?.Value ?? new ImprovedPasswordHasherOptions(); + private readonly ScryptOptions _scryptOptions = scryptOptionsAccessor?.Value ?? new ScryptOptions(); + + public string HashPassword(string password) + { + if (string.IsNullOrEmpty(password)) + throw new ArgumentNullException(nameof(password), $"{nameof(password)} should not be null"); + + if (_scryptOptions.OpsLimit.HasValue && _scryptOptions.MemLimit.HasValue) + return PasswordHash.ScryptHashString(password, _scryptOptions.OpsLimit.Value, _scryptOptions.MemLimit.Value); + + return _options.Strength switch + { + PasswordHasherStrength.Interactive => PasswordHash.ScryptHashString(password), + PasswordHasherStrength.Moderate => PasswordHash.ScryptHashString(password, PasswordHash.Strength.MediumSlow), + PasswordHasherStrength.Sensitive => PasswordHash.ScryptHashString(password, PasswordHash.Strength.Sensitive), + _ => throw new ArgumentOutOfRangeException() + }; + } + + public PasswordVerificationResult VerifyHashedPassword(string hashedPassword, string providedPassword) + { + if (string.IsNullOrEmpty(hashedPassword)) + throw new ArgumentNullException(nameof(hashedPassword), $"{nameof(hashedPassword)} should not be null"); + + if (string.IsNullOrEmpty(providedPassword)) + throw new ArgumentNullException(nameof(providedPassword), $"{nameof(providedPassword)} should not be null"); + + if (!PasswordHash.ScryptHashStringVerify(hashedPassword, providedPassword)) + return PasswordVerificationResult.Failed; + + return NeedsRehash(hashedPassword) + ? PasswordVerificationResult.SuccessRehashNeeded + : PasswordVerificationResult.Success; + } + + private bool NeedsRehash(string hashedPassword) + { + // Scrypt hash format (libsodium/escrypt): $7$$ + // The first char after "$7$" encodes N_log2 using itoa64 + if (!hashedPassword.StartsWith("$7$") || hashedPassword.Length < 4) + return false; + + var storedNLog2 = DecodeItoa64(hashedPassword[3]); + if (storedNLog2 < 0) + return false; + + var expectedNLog2 = GetExpectedNLog2(); + return storedNLog2 < expectedNLog2; + } + + private int GetExpectedNLog2() + { + if (_scryptOptions.OpsLimit.HasValue && _scryptOptions.MemLimit.HasValue) + { + // N ≈ memlimit / (r * 128), with default r=8 → N = memlimit / 1024 + var estimatedN = _scryptOptions.MemLimit.Value / 1024; + return estimatedN > 0 ? (int)Math.Log2(estimatedN) : 14; + } + + return _options.Strength switch + { + PasswordHasherStrength.Interactive => 14, // N=2^14 (16 MiB) + PasswordHasherStrength.Moderate => 17, // N=2^17 (128 MiB, MediumSlow) + PasswordHasherStrength.Sensitive => 20, // N=2^20 (1 GiB) + _ => 20 + }; + } + + /// + /// Decodes a character from the itoa64 encoding used by libsodium's scrypt. + /// Table: ./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz + /// + private static int DecodeItoa64(char c) => c switch + { + '.' => 0, + '/' => 1, + >= '0' and <= '9' => c - '0' + 2, + >= 'A' and <= 'Z' => c - 'A' + 12, + >= 'a' and <= 'z' => c - 'a' + 38, + _ => -1 + }; + } +} diff --git a/src/Security/README.md b/src/Security/README.md new file mode 100644 index 0000000..83c41b0 --- /dev/null +++ b/src/Security/README.md @@ -0,0 +1,146 @@ +# Codout.Security + +Biblioteca para hashing seguro de senhas com suporte a múltiplos algoritmos via injeção de dependência. + +## Pacotes + +| Pacote | Algoritmo | NuGet | +|---|---|---| +| `Codout.Security.Core` | Abstrações e interfaces | Dependência obrigatória | +| `Codout.Security.Argon2` | Argon2id (via libsodium) | Recomendado | +| `Codout.Security.Bcrypt` | BCrypt | Amplamente suportado | +| `Codout.Security.Scrypt` | Scrypt (via libsodium) | Alternativa | + +## Instalação + +Instale o pacote do algoritmo desejado (o Core é incluído automaticamente): + +```bash +dotnet add package Codout.Security.Argon2 +# ou +dotnet add package Codout.Security.Bcrypt +# ou +dotnet add package Codout.Security.Scrypt +``` + +## Configuração + +### Argon2 (recomendado) + +```csharp +using Codout.Security.Argon2; +using Codout.Security.Core; + +services.UpgradePasswordSecurity() + .WithStrength(PasswordHasherStrength.Sensitive) + .UseArgon2(); +``` + +Com parâmetros customizados: + +```csharp +services.UpgradePasswordSecurity() + .WithStrength(PasswordHasherStrength.Sensitive) + .UseArgon2(options => + { + options.OpsLimit = 4; // Custo computacional + options.MemLimit = 1073741824; // 1 GB de RAM + }); +``` + +### BCrypt + +```csharp +using Codout.Security.Bcrypt; +using Codout.Security.Core; + +services.UpgradePasswordSecurity() + .UseBcrypt(); +``` + +Com parâmetros customizados: + +```csharp +services.UpgradePasswordSecurity() + .UseBcrypt(options => + { + options.WorkFactor = 12; // Padrão: 12 + options.SaltRevision = BcryptSaltRevision.Revision2B; // Padrão: 2B + }); +``` + +### Scrypt + +```csharp +using Codout.Security.Scrypt; +using Codout.Security.Core; + +services.UpgradePasswordSecurity() + .WithStrength(PasswordHasherStrength.Sensitive) + .UseScrypt(); +``` + +Com parâmetros customizados: + +```csharp +services.UpgradePasswordSecurity() + .WithStrength(PasswordHasherStrength.Sensitive) + .UseScrypt(options => + { + options.OpsLimit = 4194304; + options.MemLimit = 1073741824; + }); +``` + +## Uso + +Injete `IPasswordHasher` no seu serviço: + +```csharp +public class AccountService(IPasswordHasher passwordHasher) +{ + public string CreateHash(string password) + { + return passwordHasher.HashPassword(password); + } + + public bool VerifyPassword(string hashedPassword, string providedPassword) + { + var result = passwordHasher.VerifyHashedPassword(hashedPassword, providedPassword); + + switch (result) + { + case PasswordVerificationResult.Success: + return true; + + case PasswordVerificationResult.SuccessRehashNeeded: + // Senha válida, mas o hash precisa ser atualizado + var newHash = passwordHasher.HashPassword(providedPassword); + // Persistir newHash no banco de dados + return true; + + case PasswordVerificationResult.Failed: + default: + return false; + } + } +} +``` + +## Strength + +O enum `PasswordHasherStrength` controla o custo computacional para Argon2 e Scrypt: + +| Nível | Uso de RAM | Indicação | +|---|---|---| +| `Interactive` | ~16 MB | Sessões interativas, login rápido | +| `Moderate` | ~128 MB | Uso geral | +| `Sensitive` | ~1 GB | Dados altamente sensíveis | + +> **Nota:** O `Strength` não se aplica ao BCrypt, que usa `WorkFactor` para controlar o custo. + +## SuccessRehashNeeded + +Os providers Argon2, BCrypt e Scrypt detectam automaticamente quando um hash existente foi gerado com parâmetros mais fracos que os atuais. Quando isso acontece, `VerifyHashedPassword` retorna `SuccessRehashNeeded` em vez de `Success`. + +Isso permite migrar hashes antigos de forma transparente — basta regerar o hash quando esse resultado for retornado. From 6a678acee0c9c8a002efade0e408b5fed057e956 Mon Sep 17 00:00:00 2001 From: Raphael Freitas Date: Wed, 1 Apr 2026 17:20:04 -0300 Subject: [PATCH 2/2] fix: corrigir encoding de caracteres especiais em Codout.Mailer.Razor --- Codout.Mailer.Razor/Configuration/ConfigureServices.cs | 8 +++++--- Codout.Mailer.Razor/Configuration/RazorMailerOptions.cs | 8 ++++---- Codout.Mailer.Razor/RazorViewTemplateEngine.cs | 4 ++-- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/Codout.Mailer.Razor/Configuration/ConfigureServices.cs b/Codout.Mailer.Razor/Configuration/ConfigureServices.cs index 70c6f62..19c0ce3 100644 --- a/Codout.Mailer.Razor/Configuration/ConfigureServices.cs +++ b/Codout.Mailer.Razor/Configuration/ConfigureServices.cs @@ -1,4 +1,4 @@ -using System; +using System; using Codout.Mailer.Interfaces; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; @@ -8,8 +8,8 @@ namespace Codout.Mailer.Razor.Configuration; public static class ConfigureServices { /// - /// Registra o template engine Razor nativo do ASP.NET Core para renderiza��o de templates de e-mail. - /// Deve ser chamado ap�s AddMailer(). + /// Registra o template engine Razor nativo do ASP.NET Core para renderização de templates de e-mail. + /// Deve ser chamado após AddMailer(). /// public static IServiceCollection AddMailerRazor( this IServiceCollection services, @@ -32,3 +32,5 @@ public static IServiceCollection AddMailerRazor( return services; } } + + diff --git a/Codout.Mailer.Razor/Configuration/RazorMailerOptions.cs b/Codout.Mailer.Razor/Configuration/RazorMailerOptions.cs index 98e6519..60a90be 100644 --- a/Codout.Mailer.Razor/Configuration/RazorMailerOptions.cs +++ b/Codout.Mailer.Razor/Configuration/RazorMailerOptions.cs @@ -1,15 +1,15 @@ -using System; +using System; using System.Reflection; namespace Codout.Mailer.Razor.Configuration; /// -/// Op��es de configura��o para o template engine Razor do ASP.NET Core +/// Opções de configuração para o template engine Razor do ASP.NET Core /// public class RazorMailerOptions { /// - /// Assembly que cont�m os templates Razor embarcados como recursos + /// Assembly que contém os templates Razor embarcados como recursos /// public Assembly TemplateAssembly { get; set; } @@ -19,7 +19,7 @@ public class RazorMailerOptions public string RootNamespace { get; set; } /// - /// Habilita o cache de templates compilados em mem�ria + /// Habilita o cache de templates compilados em memória /// public bool EnableCache { get; set; } = true; diff --git a/Codout.Mailer.Razor/RazorViewTemplateEngine.cs b/Codout.Mailer.Razor/RazorViewTemplateEngine.cs index 002ef3d..529c32d 100644 --- a/Codout.Mailer.Razor/RazorViewTemplateEngine.cs +++ b/Codout.Mailer.Razor/RazorViewTemplateEngine.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using System.Threading.Tasks; using Codout.Mailer.Interfaces; @@ -33,7 +33,7 @@ public async Task RenderAsync(string templateKey, T model) { var searchedLocations = string.Join(", ", viewResult.SearchedLocations ?? []); throw new InvalidOperationException( - $"Template '{templateKey}' n�o encontrado. Locais pesquisados: {searchedLocations}"); + $"Template '{templateKey}' não encontrado. Locais pesquisados: {searchedLocations}"); } await using var writer = new StringWriter();