diff --git a/SystemExtensions.NetStandard.Security.Tests/Rfc2898DeriveBytesPasswordHashingServiceTests.cs b/SystemExtensions.NetStandard.Security.Tests/Rfc2898DeriveBytesPasswordHashingServiceTests.cs new file mode 100644 index 0000000..2973a6f --- /dev/null +++ b/SystemExtensions.NetStandard.Security.Tests/Rfc2898DeriveBytesPasswordHashingServiceTests.cs @@ -0,0 +1,261 @@ +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Hashing; +using System.Threading.Tasks; + +namespace SystemExtensions.NetStandard.Security.Tests +{ + [TestClass] + public sealed class Rfc2898DeriveBytesPasswordHashingServiceTests + { + private const int TooShortHashLength = 31; + private const int DesiredHashLength = 32; + private const int Iterations = 10000; + private const int TooLittleIterations = 1000; + + private readonly Rfc2898DeriveBytesPasswordHashingService rfc2898DeriveBytesPasswordHashingService = new(); + + private byte[] tooShortSaltBytes; + private string tooShortSaltString; + private byte[] incorrectSaltBytes; + private string incorrectSaltString; + private byte[] saltBytes; + private string saltString; + private byte[] passwordBytes; + private string passwordString; + private byte[] incorrectPasswordBytes; + private string incorrectPasswordString; + + [TestInitialize] + public void TestInitialize() + { + this.tooShortSaltBytes = new byte[16]; + this.tooShortSaltString = Convert.ToBase64String(this.tooShortSaltBytes); + this.saltBytes = new byte[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31 }; + this.incorrectSaltBytes = new byte[32]; + this.incorrectSaltString = Convert.ToBase64String(this.incorrectSaltBytes); + this.saltString = Convert.ToBase64String(this.saltBytes); + this.passwordBytes = new byte[] { 5, 1, 2, 34, 35, 123, 4, 23, 1, 235, 32, 234 }; + this.passwordString = Convert.ToBase64String(this.passwordBytes); + this.incorrectPasswordBytes = new byte[] { 14, 123, 23, 4, 2, 1, 23, 25 }; + this.incorrectPasswordString = Convert.ToBase64String(this.incorrectPasswordBytes); + } + + [TestMethod] + public void PasswordNull_HashBytes_Throws_ArgumentNullException() + { + var action = new Func>(() => this.rfc2898DeriveBytesPasswordHashingService.Hash(null, this.saltBytes, DesiredHashLength, Iterations)); + + action.Should().Throw(); + } + [TestMethod] + public void PasswordNull_HashString_Throws_ArgumentNullException() + { + var action = new Func>(() => this.rfc2898DeriveBytesPasswordHashingService.Hash(null, this.saltString, DesiredHashLength, Iterations)); + + action.Should().Throw(); + } + [TestMethod] + public void SaltNull_HashBytes_Throws_ArgumentNullException() + { + var action = new Func>(() => this.rfc2898DeriveBytesPasswordHashingService.Hash(this.passwordBytes, null, DesiredHashLength, Iterations)); + + action.Should().Throw(); + } + [TestMethod] + public void SaltNull_HashString_Throws_ArgumentNullException() + { + var action = new Func>(() => this.rfc2898DeriveBytesPasswordHashingService.Hash(this.passwordString, null, DesiredHashLength, Iterations)); + + action.Should().Throw(); + } + [TestMethod] + public void HashLengthTooSmall_HashBytes_Throws_InvalidOperationException() + { + var action = new Func>(() => this.rfc2898DeriveBytesPasswordHashingService.Hash(this.passwordBytes, this.saltBytes, TooShortHashLength, Iterations)); + + action.Should().Throw(); + } + [TestMethod] + public void HashLengthTooSmall_HashString_Throws_InvalidOperationException() + { + var action = new Func>(() => this.rfc2898DeriveBytesPasswordHashingService.Hash(this.passwordString, this.saltString, TooShortHashLength, Iterations)); + + action.Should().Throw(); + } + [TestMethod] + public void TooLittleIterations_HashBytes_Throws_InvalidOperationException() + { + var action = new Func>(() => this.rfc2898DeriveBytesPasswordHashingService.Hash(this.passwordBytes, this.saltBytes, DesiredHashLength, TooLittleIterations)); + + action.Should().Throw(); + } + [TestMethod] + public void TooLittleIterations_HashString_Throws_InvalidOperationException() + { + var action = new Func>(() => this.rfc2898DeriveBytesPasswordHashingService.Hash(this.passwordString, this.saltString, DesiredHashLength, TooLittleIterations)); + + action.Should().Throw(); + } + [TestMethod] + public void TooShortSalt_HashBytes_Throws_InvalidOperationException() + { + var action = new Func>(() => this.rfc2898DeriveBytesPasswordHashingService.Hash(this.passwordBytes, this.tooShortSaltBytes, DesiredHashLength, TooLittleIterations)); + + action.Should().Throw(); + } + [TestMethod] + public void TooShortSalt_HashString_Throws_InvalidOperationException() + { + var action = new Func>(() => this.rfc2898DeriveBytesPasswordHashingService.Hash(this.passwordString, this.tooShortSaltString, DesiredHashLength, TooLittleIterations)); + + action.Should().Throw(); + } + [TestMethod] + public void PasswordNull_VerifyBytes_Throws_ArgumentNullException() + { + var action = new Func>(() => this.rfc2898DeriveBytesPasswordHashingService.VerifyPassword(null, this.incorrectPasswordBytes, this.saltBytes, Iterations)); + + action.Should().Throw(); + } + [TestMethod] + public void PasswordNull_VerifyString_Throws_ArgumentNullException() + { + var action = new Func>(() => this.rfc2898DeriveBytesPasswordHashingService.VerifyPassword(null, this.incorrectPasswordString, this.saltString, Iterations)); + + action.Should().Throw(); + } + [TestMethod] + public void HashNull_VerifyBytes_Throws_ArgumentNullException() + { + var action = new Func>(() => this.rfc2898DeriveBytesPasswordHashingService.VerifyPassword(this.passwordBytes, null, this.saltBytes, Iterations)); + + action.Should().Throw(); + } + [TestMethod] + public void HashNull_VerifyString_Throws_ArgumentNullException() + { + var action = new Func>(() => this.rfc2898DeriveBytesPasswordHashingService.VerifyPassword(this.passwordString, null, this.saltString, Iterations)); + + action.Should().Throw(); + } + [TestMethod] + public void SaltNull_VerifyBytes_Throws_ArgumentNullException() + { + var action = new Func>(() => this.rfc2898DeriveBytesPasswordHashingService.VerifyPassword(this.passwordBytes, this.incorrectPasswordBytes, null, Iterations)); + + action.Should().Throw(); + } + [TestMethod] + public void SaltNull_VerifyString_Throws_ArgumentNullException() + { + var action = new Func>(() => this.rfc2898DeriveBytesPasswordHashingService.VerifyPassword(this.passwordString, this.incorrectPasswordString, null, Iterations)); + + action.Should().Throw(); + } + [TestMethod] + public void TooLittleIterations_VerifyBytes_Throws_InvalidOperationException() + { + var action = new Func>(() => this.rfc2898DeriveBytesPasswordHashingService.VerifyPassword(this.passwordBytes, this.incorrectPasswordBytes, this.saltBytes, TooLittleIterations)); + + action.Should().Throw(); + } + [TestMethod] + public void TooLittleIterations_VerifyString_Throws_InvalidOperationException() + { + var action = new Func>(() => this.rfc2898DeriveBytesPasswordHashingService.VerifyPassword(this.passwordString, this.incorrectPasswordString, this.saltString, TooLittleIterations)); + + action.Should().Throw(); + } + [TestMethod] + public async Task HashBytes_ReturnsHashedBytes() + { + var hashedBytes = await this.rfc2898DeriveBytesPasswordHashingService.Hash(this.passwordBytes, this.saltBytes, DesiredHashLength, Iterations); + + hashedBytes.Should().NotBeNull(); + hashedBytes.Should().HaveCount(DesiredHashLength); + } + [TestMethod] + public async Task HashString_ReturnsHashedString() + { + var hashedString = await this.rfc2898DeriveBytesPasswordHashingService.Hash(this.passwordString, this.saltString, DesiredHashLength, Iterations); + + hashedString.Should().NotBeNull(); + var hashedBytes = Convert.FromBase64String(hashedString); + hashedBytes.Should().HaveCount(DesiredHashLength); + } + [TestMethod] + public async Task VerifyBytes_CorrectPassword_ReturnsTrue() + { + var hashedBytes = await this.rfc2898DeriveBytesPasswordHashingService.Hash(this.passwordBytes, this.saltBytes, DesiredHashLength, Iterations); + + var result = await this.rfc2898DeriveBytesPasswordHashingService.VerifyPassword(hashedBytes, this.passwordBytes, this.saltBytes, Iterations); + + result.Should().BeTrue(); + } + [TestMethod] + public async Task VerifyString_CorrectPassword_ReturnsTrue() + { + var hashedString = await this.rfc2898DeriveBytesPasswordHashingService.Hash(this.passwordString, this.saltString, DesiredHashLength, Iterations); + + var result = await this.rfc2898DeriveBytesPasswordHashingService.VerifyPassword(hashedString, this.passwordString, this.saltString, Iterations); + + result.Should().BeTrue(); + } + [TestMethod] + public async Task VerifyBytes_IncorrectPassword_ReturnsFalse() + { + var hashedBytes = await this.rfc2898DeriveBytesPasswordHashingService.Hash(this.passwordBytes, this.saltBytes, DesiredHashLength, Iterations); + + var result = await this.rfc2898DeriveBytesPasswordHashingService.VerifyPassword(hashedBytes, this.incorrectPasswordBytes, this.saltBytes, Iterations); + + result.Should().BeFalse(); + } + [TestMethod] + public async Task VerifyString_IncorrectPassword_ReturnsFalse() + { + var hashedString = await this.rfc2898DeriveBytesPasswordHashingService.Hash(this.passwordString, this.saltString, DesiredHashLength, Iterations); + + var result = await this.rfc2898DeriveBytesPasswordHashingService.VerifyPassword(hashedString, this.incorrectPasswordString, this.saltString, Iterations); + + result.Should().BeFalse(); + } + [TestMethod] + public async Task VerifyBytes_IncorrectSalt_ReturnsFalse() + { + var hashedBytes = await this.rfc2898DeriveBytesPasswordHashingService.Hash(this.passwordBytes, this.saltBytes, DesiredHashLength, Iterations); + + var result = await this.rfc2898DeriveBytesPasswordHashingService.VerifyPassword(hashedBytes, this.passwordBytes, this.incorrectSaltBytes, Iterations); + + result.Should().BeFalse(); + } + [TestMethod] + public async Task VerifyString_IncorrectSalt_ReturnsFalse() + { + var hashedString = await this.rfc2898DeriveBytesPasswordHashingService.Hash(this.passwordString, this.saltString, DesiredHashLength, Iterations); + + var result = await this.rfc2898DeriveBytesPasswordHashingService.VerifyPassword(hashedString, this.passwordString, this.incorrectSaltString, Iterations); + + result.Should().BeFalse(); + } + [TestMethod] + public async Task VerifyBytes_IncorrectIterations_ReturnsFalse() + { + var hashedBytes = await this.rfc2898DeriveBytesPasswordHashingService.Hash(this.passwordBytes, this.saltBytes, DesiredHashLength, Iterations); + + var result = await this.rfc2898DeriveBytesPasswordHashingService.VerifyPassword(hashedBytes, this.passwordBytes, this.saltBytes, Iterations + 1); + + result.Should().BeFalse(); + } + [TestMethod] + public async Task VerifyString_IncorrectIterations_ReturnsFalse() + { + var hashedString = await this.rfc2898DeriveBytesPasswordHashingService.Hash(this.passwordString, this.saltString, DesiredHashLength, Iterations); + + var result = await this.rfc2898DeriveBytesPasswordHashingService.VerifyPassword(hashedString, this.passwordString, this.saltString, Iterations + 1); + + result.Should().BeFalse(); + } + } +} diff --git a/SystemExtensions.NetStandard.Security/Hashing/IPasswordHashingService.cs b/SystemExtensions.NetStandard.Security/Hashing/IPasswordHashingService.cs new file mode 100644 index 0000000..adc6a6f --- /dev/null +++ b/SystemExtensions.NetStandard.Security/Hashing/IPasswordHashingService.cs @@ -0,0 +1,47 @@ +using System.Threading.Tasks; + +namespace System.Hashing +{ + public interface IPasswordHashingService + { + /// + /// Hash provided raw password and return the hashed value. + /// + /// Base64 encoded password. + /// Base64 encoded salt to be used for hashing. + /// Length of the resulting hash. + /// Number of hashing iterations to be performed. + /// + /// The length of the hash will vary due to the base64 encoding of the resulting hash. + /// + /// Base64 encoded hashed password. + Task Hash(string raw, string salt, int length, int iterations); + /// + /// Hash provided raw password and return the hashed value. + /// + /// Password to be hashed. + /// Salt to be used for hashing. + /// Length of the resulting hash. + /// Number of hashing iterations to be performed. + /// Hashed password. + Task Hash(byte[] raw, byte[] salt, int length, int iterations); + /// + /// Verify provided password against a hashed password. + /// + /// Base64 encoding of the previously hashed password. + /// Base64 encoding of the password to be verified. + /// Salt used to hash the previous password. + /// Number of iterations used to hash the previous password. + /// Returns true if password matches the previously hashed password. Otherwise returns false. + Task VerifyPassword(string hash, string password, string salt, int iterations); + /// + /// Verify provided password against a hashed password. + /// + /// Previously hashed password. + /// Password to be verified. + /// Salt used to hash the previous password. + /// Number of iterations used to hash the previous password. + /// Returns true if password matches the previously hashed password. Otherwise returns false. + Task VerifyPassword(byte[] hash, byte[] password, byte[] salt, int iterations); + } +} diff --git a/SystemExtensions.NetStandard.Security/Hashing/Rfc2898DeriveBytesPasswordHashingService.cs b/SystemExtensions.NetStandard.Security/Hashing/Rfc2898DeriveBytesPasswordHashingService.cs new file mode 100644 index 0000000..b15ac16 --- /dev/null +++ b/SystemExtensions.NetStandard.Security/Hashing/Rfc2898DeriveBytesPasswordHashingService.cs @@ -0,0 +1,101 @@ +using System.Security.Cryptography; +using System.Threading.Tasks; + +namespace System.Hashing +{ + public sealed class Rfc2898DeriveBytesPasswordHashingService : IPasswordHashingService + { + private const int MinimumIterations = 10000; + private const int MinimumHashLength = 32; + + public Rfc2898DeriveBytesPasswordHashingService() + { + } + + public async Task Hash(string raw, string salt, int length, int iterations) + { + this.ValidateHashArguments(raw, salt, length, iterations); + + var rawBytes = Convert.FromBase64String(raw); + var saltBytes = Convert.FromBase64String(salt); + var hashBytes = await this.HashInternal(rawBytes, saltBytes, length, iterations); + return Convert.ToBase64String(hashBytes); + } + public Task Hash(byte[] raw, byte[] salt, int length, int iterations) + { + this.ValidateHashArguments(raw, salt, length, iterations); + + return this.HashInternal(raw, salt, length, iterations); + } + public Task VerifyPassword(string hash, string password, string salt, int iterations) + { + this.ValidateVerifyArguments(hash, password, salt, iterations); + var hashBytes = Convert.FromBase64String(hash); + var saltBytes = Convert.FromBase64String(salt); + var passwordBytes = Convert.FromBase64String(password); + return this.VerifyPasswordInternal(hashBytes, passwordBytes, saltBytes, iterations); + } + public Task VerifyPassword(byte[] hash, byte[] password, byte[] salt, int iterations) + { + this.ValidateVerifyArguments(hash, password, salt, iterations); + return this.VerifyPasswordInternal(hash, password, salt, iterations); + } + + private void ValidateHashArguments(object raw, object salt, int length, int iterations) + { + _ = raw ?? throw new ArgumentNullException(nameof(raw)); + _ = salt ?? throw new ArgumentNullException(nameof(salt)); + + if (iterations < MinimumIterations) + { + throw new InvalidOperationException($"Unable to perform secure hash. Iteration count must be over {MinimumIterations}"); + } + + if (length < MinimumHashLength) + { + throw new InvalidOperationException($"Unable to perform secure hash. Hash length must be over {MinimumHashLength}"); + } + } + private void ValidateVerifyArguments(object hash, object password, object salt, int iterations) + { + _ = hash ?? throw new ArgumentNullException(nameof(hash)); + _ = salt ?? throw new ArgumentNullException(nameof(salt)); + _ = password ?? throw new ArgumentNullException(nameof(password)); + + if (iterations < MinimumIterations) + { + throw new InvalidOperationException($"Unable to verify hash. Iteration count must be over {MinimumIterations}"); + } + } + private Task HashInternal(byte[] raw, byte[] salt, int length, int iterations) + { + using var pbkdf2 = new Rfc2898DeriveBytes(raw, salt, iterations); + var hash = pbkdf2.GetBytes(length); + return Task.FromResult(hash); + } + private Task VerifyPasswordInternal(byte[] hash, byte[] password, byte[] salt, int iterations) + { + using var pbkdf2 = new Rfc2898DeriveBytes(password, salt, iterations); + var hashToVerify = pbkdf2.GetBytes(hash.Length); + var lengthToVerify = Math.Max(hash.Length, hashToVerify.Length); + var match = true; + if (lengthToVerify <= 0) + { + return Task.FromResult(false); + } + + for(var i = 0; i < lengthToVerify; i++) + { + if (i < hash.Length && i < hashToVerify.Length) + { + if (hashToVerify[i].Equals(hash[i]) is false) + { + match = false; + } + } + } + + return Task.FromResult(match && hash.Length.Equals(hashToVerify.Length)); + } + } +} diff --git a/SystemExtensions.NetStandard.Security/Rng/ICryptoRngProvider.cs b/SystemExtensions.NetStandard.Security/Rng/ICryptoRngProvider.cs index 094dcad..fd25834 100644 --- a/SystemExtensions.NetStandard.Security/Rng/ICryptoRngProvider.cs +++ b/SystemExtensions.NetStandard.Security/Rng/ICryptoRngProvider.cs @@ -2,9 +2,27 @@ { public interface ICryptoRngProvider { + /// + /// Populate provided array of bytes with cryptographically secure random bytes. + /// + /// Array of bytes to be populated. public void GetBytes(byte[] data); + /// + /// Return an array of cryptographically secure random bytes. + /// + /// Length of the returned array. + /// Array containing cryptographically secure random values. public byte[] GetBytes(int byteCount); + /// + /// Populate provided array of bytes with cryptographically secure random non-zero bytes. + /// + /// Array of bytes to be populated. public void GetNonZeroBytes(byte[] data); + /// + /// Return an array of cryptographically secure random non-zero bytes. + /// + /// Length of the returned array. + /// Array containing cryptographically secure random non-zero values. public byte[] GetNonZeroBytes(int byteCount); } } diff --git a/SystemExtensions.NetStandard.Security/SystemExtensions.NetStandard.Security.csproj b/SystemExtensions.NetStandard.Security/SystemExtensions.NetStandard.Security.csproj index 0a9f3b3..16141a2 100644 --- a/SystemExtensions.NetStandard.Security/SystemExtensions.NetStandard.Security.csproj +++ b/SystemExtensions.NetStandard.Security/SystemExtensions.NetStandard.Security.csproj @@ -7,7 +7,7 @@ LICENSE true System - 1.1.1 + 1.2.0 Alexandru Macocian https://github.com/AlexMacocian/SystemExtensions