From 647b185d408d15aed8c6ae072d91305b6c48799a Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sun, 1 Feb 2026 11:32:14 -0600 Subject: [PATCH] feat: implement password hasher --- src/FiscalOS.API/Identity/PasswordHasher.cs | 66 +++++++++++++++++++ .../Unit/PasswordHasherTests.cs | 42 ++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 src/FiscalOS.API/Identity/PasswordHasher.cs create mode 100644 tests/FiscalOS.API.Tests/Unit/PasswordHasherTests.cs diff --git a/src/FiscalOS.API/Identity/PasswordHasher.cs b/src/FiscalOS.API/Identity/PasswordHasher.cs new file mode 100644 index 0000000..31ee6aa --- /dev/null +++ b/src/FiscalOS.API/Identity/PasswordHasher.cs @@ -0,0 +1,66 @@ +using System.Security.Cryptography; + +namespace FiscalOS.API.Identity; + +internal interface IPasswordHasher +{ + string Hash(string password); + bool Verify(string providedPassword, string hashedPassword); +} + +internal class PasswordHasher : IPasswordHasher +{ + private const int SaltSize = 16; + private const int HashSize = 32; + private const int Iterations = 100_000; + + private static readonly HashAlgorithmName HashAlgorithm = HashAlgorithmName.SHA512; + + private PasswordHasher() + { + } + + public static PasswordHasher New() + { + return new(); + } + + public string Hash(string password) + { + var salt = RandomNumberGenerator.GetBytes(SaltSize); + var hash = Rfc2898DeriveBytes.Pbkdf2( + password, + salt, + Iterations, + HashAlgorithm, + HashSize + ); + + var hashBytes = new byte[SaltSize + HashSize]; + Array.Copy(salt, 0, hashBytes, 0, SaltSize); + Array.Copy(hash, 0, hashBytes, SaltSize, HashSize); + + return Convert.ToBase64String(hashBytes); + } + + public bool Verify(string providedPassword, string hashedPassword) + { + var hashBytes = Convert.FromBase64String(hashedPassword); + + var salt = new byte[SaltSize]; + Array.Copy(hashBytes, 0, salt, 0, SaltSize); + + var storedHash = new byte[HashSize]; + Array.Copy(hashBytes, SaltSize, storedHash, 0, HashSize); + + var computedHash = Rfc2898DeriveBytes.Pbkdf2( + providedPassword, + salt, + Iterations, + HashAlgorithm, + HashSize + ); + + return CryptographicOperations.FixedTimeEquals(storedHash, computedHash); + } +} \ No newline at end of file diff --git a/tests/FiscalOS.API.Tests/Unit/PasswordHasherTests.cs b/tests/FiscalOS.API.Tests/Unit/PasswordHasherTests.cs new file mode 100644 index 0000000..2703bd0 --- /dev/null +++ b/tests/FiscalOS.API.Tests/Unit/PasswordHasherTests.cs @@ -0,0 +1,42 @@ +using FiscalOS.API.Identity; + +namespace FiscalOS.API.Tests.Unit; + +public class PasswordHasherTests +{ + private readonly PasswordHasher _sut = PasswordHasher.New(); + + [Fact] + public void Hash_WhenGivenPassword_ShouldReturnHashedPassword() + { + var password = "SecurePassword123!"; + + var result = _sut.Hash(password); + + result.Should().NotBeNullOrEmpty(); + result.Should().NotBe(password); + } + + [Fact] + public void Verify_WhenGivenCorrectPassword_ShouldReturnTrue() + { + var password = "SecurePassword123!"; + var hashedPassword = _sut.Hash(password); + + var result = _sut.Verify(password, hashedPassword); + + result.Should().BeTrue(); + } + + [Fact] + public void Verify_WhenGivenIncorrectPassword_ShouldReturnFalse() + { + var password = "SecurePassword123!"; + var wrongPassword = "WrongPassword!"; + var hashedPassword = _sut.Hash(password); + + var result = _sut.Verify(wrongPassword, hashedPassword); + + result.Should().BeFalse(); + } +} \ No newline at end of file