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);
}
}
}