As part of a larger authentication/authorization system, I've developed a small .NET Core 3.0 service for hashing passwords using PBKDF2 (with a salt) and validating passwords against a stored hash/salt, for use as part of a larger authentication/authorization system.
Code organization:
IPasswordServiceis the main interface used by the larger system.PasswordServiceis the implementation, the heart of this body of code.KeyDerivationParametersandCryptoRngare used when configuring and loading the service (ideally through a DI container).ICryptoRngis used as an interface to stub out when unit testingPasswordService.ValueTypescontains domain-specific types wrapping primitives, using the ValueOf library.HashedPasswordis a simple wrapper encapsulating both a hash and a salt.PasswordServiceTestscontains my unit tests.
My main concerns (though all feedback is welcome):
- Security - making sure there's no obvious attack I'm missing. I should note that I only chose PBKDF2 because it has an implementation in .NET; argon and bcrypt are only available in C# through third-party libraries in various states of maintenance. I also intend on using a larger iteration count in practice than the 10,000 iterations specified in the tests; I wanted to keep the unit tests fast.
 - Tests - I only have four unit tests for the 
PasswordService, and I'm not confident that they provide enough of a safety net. - The use of ValueOf to create meaningful domain-specific types instead of using primitives everywhere. In Haskell, using newtypes for this sort of thing is common, but I'm curious what C# developers think about it.
 
The code is broken up into eight files across two projects; it's available on GitHub here, as well as below. The projects are .NET Core 3.0, using
<PropertyGroup>
  <LangVersion>8.0</LangVersion>
  <Nullable>enable</Nullable>
</PropertyGroup>
in the .csproj files to enable C# 8's nullable reference types.
IPasswordService.cs
namespace AuthSystemPasswordService.Interfaces
{
    public interface IPasswordService
    {
        HashedPassword GeneratePasswordHashAndSalt(PlaintextPassword password);
        bool CheckIfPasswordMatchesHash(PlaintextPassword password, HashedPassword hash);
    }
}
ICryptoRng.cs
namespace AuthSystemPasswordService.Interfaces
{
    public interface ICryptoRng
    {
        byte[] GetRandomBytes(int numBytes);
    }
}
ValueTypes.cs
using ValueOf;
namespace AuthSystemPasswordService
{
    public class PlaintextPassword : ValueOf<string, PlaintextPassword>
    {
    }
    public class Base64Hash : ValueOf<string, Base64Hash>
    {
    }
    public class Base64Salt : ValueOf<string, Base64Salt>
    {
    }
    public class IterationCount : ValueOf<int, IterationCount>
    {
    }
    public class SaltLength : ValueOf<int, SaltLength>
    {
    }
    public class KeyLength : ValueOf<int, KeyLength>
    {
    }
}
HashedPassword.cs
namespace AuthSystemPasswordService
{
    public struct HashedPassword
    {
        public Base64Hash Base64PasswordHash { get; }
        public Base64Salt Base64Salt { get; }
        public HashedPassword(Base64Hash passwordHash, Base64Salt salt)
        {
            Base64PasswordHash = passwordHash;
            Base64Salt = salt;
        }
    }
}
KeyDerivationParameters.cs
using Microsoft.AspNetCore.Cryptography.KeyDerivation;
namespace AuthSystemPasswordService
{
    public struct KeyDerivationParameters
    {
        public KeyDerivationPrf DerivationFunction { get; }
        public IterationCount IterationCount { get; }
        public SaltLength SaltLength { get; }
        public KeyLength KeyLength { get; }
        public KeyDerivationParameters(KeyDerivationPrf derivationFunction, IterationCount iterationCount,
            SaltLength saltLength, KeyLength keyLength)
        {
            DerivationFunction = derivationFunction;
            IterationCount = iterationCount;
            SaltLength = saltLength;
            KeyLength = keyLength;
        }
    }
}
CryptoRng.cs
using AuthSystemPasswordService.Interfaces;
using System.Security.Cryptography;
namespace AuthSystemPasswordService.Services
{
    public class CryptoRng : ICryptoRng
    {
        private RandomNumberGenerator Generator { get; }
        public CryptoRng()
        {
            Generator = RandomNumberGenerator.Create();
        }
        public byte[] GetRandomBytes(int numBytes)
        {
            var bytes = new byte[numBytes];
            Generator.GetBytes(bytes);
            return bytes;
        }
    }
}
PasswordService
using AuthSystemPasswordService.Interfaces;
using Microsoft.AspNetCore.Cryptography.KeyDerivation;
using System;
using System.Linq;
namespace AuthSystemPasswordService.Services
{
    public class PasswordService : IPasswordService
    {
        private KeyDerivationParameters Parameters { get; }
        private ICryptoRng Rng { get; }
        public PasswordService(KeyDerivationParameters parameters, ICryptoRng rng)
        {
            Parameters = parameters;
            Rng = rng;
        }
        public HashedPassword GeneratePasswordHashAndSalt(PlaintextPassword password)
        {
            var saltBytes = Rng.GetRandomBytes(Parameters.SaltLength.Value);
            var salt = Convert.ToBase64String(saltBytes);
            var hashBytes = KeyDerivation.Pbkdf2(password.Value, saltBytes, Parameters.DerivationFunction,
                Parameters.IterationCount.Value, Parameters.KeyLength.Value);
            var hash = Convert.ToBase64String(hashBytes);
            return new HashedPassword(Base64Hash.From(hash), Base64Salt.From(salt));
        }
        public bool CheckIfPasswordMatchesHash(PlaintextPassword password, HashedPassword hash)
        {
            var passwordHash = KeyDerivation.Pbkdf2(password.Value, Convert.FromBase64String(hash.Base64Salt.Value), Parameters.DerivationFunction,
                Parameters.IterationCount.Value, Parameters.KeyLength.Value);
            return passwordHash.SequenceEqual(Convert.FromBase64String(hash.Base64PasswordHash.Value));
        }
    }
}
PasswordServiceTests
using AuthSystemPasswordService.Interfaces;
using AuthSystemPasswordService.Services;
using Microsoft.AspNetCore.Cryptography.KeyDerivation;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using NSubstitute;
using System;
namespace AuthSystemPasswordService.Test
{
    [TestClass]
    public class PasswordServiceTests
    {
        [TestMethod]
        [TestCategory("UnitTest")]
        public void GenerateHashAndSalt_ReturnsSalt_WithNumberOfBytesEqualToSaltLengthParameter()
        {
            // Arrange
            var iterationCount = 10_000;
            var saltLength = 16;
            var keyLength = 64;
            var parameters = new KeyDerivationParameters(KeyDerivationPrf.HMACSHA512,
                IterationCount.From(iterationCount), SaltLength.From(saltLength), KeyLength.From(keyLength));
            var rng = Substitute.For<ICryptoRng>();
            rng.GetRandomBytes(Arg.Any<int>()).Returns(args => new byte[args.Arg<int>()]);
            var service = new PasswordService(parameters, rng);
            // Act
            var hash = service.GeneratePasswordHashAndSalt(PlaintextPassword.From("somePassword"));
            // Assert
            Assert.AreEqual(saltLength, Convert.FromBase64String(hash.Base64Salt.Value).Length);
        }
        [TestMethod]
        [TestCategory("UnitTest")]
        public void GenerateHashAndSalt_ReturnsHash_WithNumberOfBytesEqualToKeyLengthParameter()
        {
            // Arrange
            var iterationCount = 10_000;
            var saltLength = 16;
            var keyLength = 64;
            var parameters = new KeyDerivationParameters(KeyDerivationPrf.HMACSHA512,
                IterationCount.From(iterationCount), SaltLength.From(saltLength), KeyLength.From(keyLength));
            var rng = Substitute.For<ICryptoRng>();
            rng.GetRandomBytes(Arg.Any<int>()).Returns(args => new byte[args.Arg<int>()]);
            var service = new PasswordService(parameters, rng);
            // Act
            var hash = service.GeneratePasswordHashAndSalt(PlaintextPassword.From("somePassword"));
            // Assert
            Assert.AreEqual(keyLength, Convert.FromBase64String(hash.Base64PasswordHash.Value).Length);
        }
        [TestMethod]
        [TestCategory("UnitTest")]
        public void GenerateHashAndSalt_ThenCheckingSamePassword_ReturnsTrue()
        {
            // Arrange
            var iterationCount = 10_000;
            var saltLength = 16;
            var keyLength = 64;
            var parameters = new KeyDerivationParameters(KeyDerivationPrf.HMACSHA512,
                IterationCount.From(iterationCount), SaltLength.From(saltLength), KeyLength.From(keyLength));
            var rng = Substitute.For<ICryptoRng>();
            rng.GetRandomBytes(Arg.Any<int>()).Returns(args => new byte[args.Arg<int>()]);
            var service = new PasswordService(parameters, rng);
            var password = PlaintextPassword.From("somePass");
            // Act
            var hash = service.GeneratePasswordHashAndSalt(password);
            var checkResult = service.CheckIfPasswordMatchesHash(password, hash);
            // Assert
            Assert.IsTrue(checkResult);
        }
        [TestMethod]
        [TestCategory("UnitTest")]
        public void GenerateHashAndSalt_ThenCheckingOtherPassword_ReturnsFalse()
        {
            // Arrange
            var iterationCount = 10_000;
            var saltLength = 16;
            var keyLength = 64;
            var parameters = new KeyDerivationParameters(KeyDerivationPrf.HMACSHA512,
                IterationCount.From(iterationCount), SaltLength.From(saltLength), KeyLength.From(keyLength));
            var rng = Substitute.For<ICryptoRng>();
            rng.GetRandomBytes(Arg.Any<int>()).Returns(args => new byte[args.Arg<int>()]);
            var service = new PasswordService(parameters, rng);
            var password = PlaintextPassword.From("somePass");
            var otherPass = PlaintextPassword.From("otherPass");
            // Act
            var hash = service.GeneratePasswordHashAndSalt(password);
            var checkResult = service.CheckIfPasswordMatchesHash(otherPass, hash);
            // Assert
            Assert.IsFalse(checkResult);
        }
    }
}