Add credential verification helpers

This commit is contained in:
Jordan Wages 2026-07-20 00:20:55 -05:00
commit 8b373ebfe8
2 changed files with 108 additions and 36 deletions

View file

@ -15,9 +15,6 @@ namespace CapyKit.Helpers
{ {
#region Members #region Members
/// <summary> Default size to use when generating a new salt. </summary>
private const int SALT_SIZE = 32;
/// <summary> A string of all the lower case characters. </summary> /// <summary> A string of all the lower case characters. </summary>
internal const string LOWER_CASE_CHARACTERS = "abcdefghijklmnopqrstuvwxyz"; internal const string LOWER_CASE_CHARACTERS = "abcdefghijklmnopqrstuvwxyz";
@ -34,6 +31,14 @@ namespace CapyKit.Helpers
#region Methods #region Methods
public static bool CompareHashedPassword<T>(Password existingPassword, string password, string salt,
params object[] args)
{
var saltBytes = Convert.FromBase64String(salt);
return CompareHashedPassword<T>(existingPassword, password, saltBytes, args);
}
/// <summary> /// <summary>
/// Compares an unencrypted <paramref name="password"/> with a stored, encrypted <paramref name="existingPassword"/>. /// Compares an unencrypted <paramref name="password"/> with a stored, encrypted <paramref name="existingPassword"/>.
/// This method uses the specified password algorithm type <typeparamref name="T"/> to retrieve the hashed version /// This method uses the specified password algorithm type <typeparamref name="T"/> to retrieve the hashed version
@ -47,7 +52,8 @@ namespace CapyKit.Helpers
/// <returns> /// <returns>
/// <see langword="true"/> if hash comparison succeeds, <see langword="false"/> if it fails. /// <see langword="true"/> if hash comparison succeeds, <see langword="false"/> if it fails.
/// </returns> /// </returns>
public static bool CompareHashedPassword<T>(Password existingPassword, string password, byte[] salt, params object[] args) public static bool CompareHashedPassword<T>(Password existingPassword, string password, byte[] salt,
params object[] args)
{ {
var providedPassword = typeof(SecurityHelper) var providedPassword = typeof(SecurityHelper)
.GetMethod("GetPassword", new Type[] { typeof(string), typeof(byte[]), typeof(object[]) }) .GetMethod("GetPassword", new Type[] { typeof(string), typeof(byte[]), typeof(object[]) })
@ -57,6 +63,14 @@ namespace CapyKit.Helpers
return existingPassword.Equals(providedPassword); return existingPassword.Equals(providedPassword);
} }
public static bool CompareHashedPassword(Password existingPassword, string password, string salt,
IPasswordAlgorithm algorithm, params object[] args)
{
var saltBytes = Convert.FromBase64String(salt);
return CompareHashedPassword(existingPassword, password, saltBytes, algorithm, args);
}
/// <summary> /// <summary>
/// Compares an unencrypted <paramref name="password"/> with a stored, encrypted <paramref name="existingPassword"/>. /// Compares an unencrypted <paramref name="password"/> with a stored, encrypted <paramref name="existingPassword"/>.
/// This method uses the specified <paramref name="algorithm"/> to retrieve the hashed version /// This method uses the specified <paramref name="algorithm"/> to retrieve the hashed version
@ -70,7 +84,8 @@ namespace CapyKit.Helpers
/// <returns> /// <returns>
/// <see langword="true"/> if hash comparison succeeds, <see langword="false"/> if it fails. /// <see langword="true"/> if hash comparison succeeds, <see langword="false"/> if it fails.
/// </returns> /// </returns>
public static bool CompareHashedPassword(Password existingPassword, string password, byte[] salt, IPasswordAlgorithm algorithm, params object[] args) public static bool CompareHashedPassword(Password existingPassword, string password, byte[] salt,
IPasswordAlgorithm algorithm, params object[] args)
{ {
var algorithmType = algorithm.GetType(); var algorithmType = algorithm.GetType();
@ -82,6 +97,36 @@ namespace CapyKit.Helpers
return existingPassword.Equals(providedPassword); return existingPassword.Equals(providedPassword);
} }
/// <summary>
/// Verifies a plaintext password against a persisted PBKDF2 hash and salt.
/// </summary>
/// <param name="password">The plaintext password supplied by the caller.</param>
/// <param name="storedHash">The persisted PBKDF2 hash.</param>
/// <param name="salt">The persisted PBKDF2 salt.</param>
/// <returns><see langword="true"/> when the password matches the persisted hash.</returns>
public static bool VerifyPbkdf2(string password, byte[] storedHash, byte[] salt)
{
if (string.IsNullOrEmpty(password) || storedHash == null || salt == null)
{
return false;
}
var candidateHash = Pbkdf2(password, salt).Hash;
return CryptographicOperations.FixedTimeEquals(storedHash, candidateHash);
}
/// <summary>
/// Produces a deterministic SHA-256 digest for an opaque secret that must be looked up
/// without persisting the raw value.
/// </summary>
/// <param name="secret">The raw opaque secret.</param>
/// <returns>The Base64-encoded digest.</returns>
public static string HashOpaqueSecret(string secret)
{
ArgumentException.ThrowIfNullOrWhiteSpace(secret);
return Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(secret)));
}
/// <summary> /// <summary>
/// Retrieves a <see cref="Password"/> object using the specified password and generates a random salt value. /// Retrieves a <see cref="Password"/> object using the specified password and generates a random salt value.
/// Then it uses that salt to call the overloaded <see cref="GetPassword{T}(string, byte[], object[])"/> method with the given password and /// Then it uses that salt to call the overloaded <see cref="GetPassword{T}(string, byte[], object[])"/> method with the given password and
@ -98,9 +143,9 @@ namespace CapyKit.Helpers
/// instance of <typeparamref name="T"/> created using any optional constructor arguments provided. /// instance of <typeparamref name="T"/> created using any optional constructor arguments provided.
/// </returns> /// </returns>
/// <seealso cref="SecurityHelper.SALT_SIZE"/> /// <seealso cref="SecurityHelper.SALT_SIZE"/>
public static Password GetPassword<T>(string password, params object[] args) public static Password GetPassword<T>(string password, int saltSize, params object[] args) where T : IPasswordAlgorithm
{ {
var salt = SecurityHelper.GetSalt; var salt = GetRandomBytes(saltSize);
return GetPassword<T>(password, salt, args); return GetPassword<T>(password, salt, args);
} }
@ -129,14 +174,16 @@ namespace CapyKit.Helpers
/// A new <see cref="Password"/> object with the given password and salt, as well as an instance /// A new <see cref="Password"/> object with the given password and salt, as well as an instance
/// of <typeparamref name="T"/> created using the provided constructor arguments. /// of <typeparamref name="T"/> created using the provided constructor arguments.
/// </returns> /// </returns>
public static Password GetPassword<T>(string password, byte[] salt, params object[] args) where T : IPasswordAlgorithm public static Password GetPassword<T>(string password, byte[] salt, params object[] args)
where T : IPasswordAlgorithm
{ {
//var allArgs = args.Prepend(salt).Prepend(password).ToArray(); // Prepend in reverse order so that password precedes salt. //var allArgs = args.Prepend(salt).Prepend(password).ToArray(); // Prepend in reverse order so that password precedes salt.
var argTypes = args.Select(arg => arg.GetType()).ToArray(); var argTypes = args.Select(arg => arg.GetType()).ToArray();
var algorithmConstructor = typeof(T).GetConstructor(argTypes); var algorithmConstructor = typeof(T).GetConstructor(argTypes);
if (algorithmConstructor == null) if (algorithmConstructor == null)
{ {
CapyEventReporter.EmitEvent(EventLevel.Error, "Cannot find a constructor for {0} that matches the given arguments: {1}", CapyEventReporter.EmitEvent(EventLevel.Error,
"Cannot find a constructor for {0} that matches the given arguments: {1}",
args: new[] args: new[]
{ {
typeof(T).Name, typeof(T).Name,
@ -149,7 +196,8 @@ namespace CapyKit.Helpers
if (passwordInstance == null) if (passwordInstance == null)
{ {
CapyEventReporter.EmitEvent(EventLevel.Error, "There was an error invoking the constructor for {0} with the given arguments: {1}", CapyEventReporter.EmitEvent(EventLevel.Error,
"There was an error invoking the constructor for {0} with the given arguments: {1}",
args: new[] args: new[]
{ {
typeof(T).Name, typeof(T).Name,
@ -198,9 +246,9 @@ namespace CapyKit.Helpers
/// <returns> /// <returns>
/// A new <see cref="Password"/> object containing the hashed password and a randomly generated salt. /// A new <see cref="Password"/> object containing the hashed password and a randomly generated salt.
/// </returns> /// </returns>
public static Password Pbkdf2(string password) public static Password Pbkdf2(string password, int saltSize)
{ {
var salt = SecurityHelper.GetRandomBytes(SALT_SIZE); var salt = SecurityHelper.GetRandomBytes(saltSize);
var pwd = new Password(password, salt, Password.Pbkdf2Algorithm); var pwd = new Password(password, salt, Password.Pbkdf2Algorithm);
return pwd; return pwd;
@ -217,9 +265,15 @@ namespace CapyKit.Helpers
{ {
if (validCharacters.Length == 0) if (validCharacters.Length == 0)
{ {
CapyEventReporter.EmitEvent(EventLevel.Warning, "No valid characters were provided, so all valid caharacters will be assumed."); CapyEventReporter.EmitEvent(EventLevel.Warning,
validCharacters = new[] { ValidCharacterCollection.Lowercase, ValidCharacterCollection.Uppercase, ValidCharacterCollection.Numbers, ValidCharacterCollection.Special }; "No valid characters were provided, so all valid caharacters will be assumed.");
validCharacters = new[]
{
ValidCharacterCollection.Lowercase, ValidCharacterCollection.Uppercase,
ValidCharacterCollection.Numbers, ValidCharacterCollection.Special
};
} }
return GetRandomString(length, validCharacters); return GetRandomString(length, validCharacters);
} }
@ -235,6 +289,7 @@ namespace CapyKit.Helpers
{ {
return CompareStrings(first, second); return CompareStrings(first, second);
} }
return CompareStrings(first.Trim(), second.Trim()); return CompareStrings(first.Trim(), second.Trim());
} }
@ -259,9 +314,12 @@ namespace CapyKit.Helpers
public static string GetRandomString(int length, params ValidCharacterCollection[] validChars) public static string GetRandomString(int length, params ValidCharacterCollection[] validChars)
{ {
var buffer = new StringBuilder(length); var buffer = new StringBuilder(length);
var randomNumberBuffer = new byte[length * 3]; // Overprovision the buffer so we can discard a small percentage. var randomNumberBuffer =
new byte[length * 3]; // Overprovision the buffer so we can discard a small percentage.
var validCharacters = GetValidCharacterComposition(validChars); var validCharacters = GetValidCharacterComposition(validChars);
var validByteUpperLimit = (256 / validCharacters.Length) * validCharacters.Length - 1; // Maintains equal distribution of valid characters. var validByteUpperLimit =
(256 / validCharacters.Length) * validCharacters.Length -
1; // Maintains equal distribution of valid characters.
using (var rng = RandomNumberGenerator.Create()) using (var rng = RandomNumberGenerator.Create())
{ {
@ -286,22 +344,11 @@ namespace CapyKit.Helpers
return buffer.ToString(); return buffer.ToString();
} }
/// <summary> Generates a random byte array that can act as a salt. </summary>
/// <remarks>
/// A default length of <see cref="SALT_SIZE"/> is provided as a sane default. Larger values can be used for increased
/// entropy.
/// </remarks>
/// <param name="length"> (Optional) The desired length of the generated byte array. </param>
/// <returns> An array of byte. </returns>
public static byte[] GetSalt(int length = SALT_SIZE)
{
return GetRandomBytes(length);
}
/// <summary> Generates a new byte array of the specified length with random values. </summary> /// <summary> Generates a new byte array of the specified length with random values. </summary>
/// <param name="length"> The desired length of the generated byte array. </param> /// <param name="length"> The desired length of the generated byte array. </param>
/// <returns> A new byte array of the specified length filled with random values. </returns> /// <returns> A new byte array of the specified length filled with random values. </returns>
private static byte[] GetRandomBytes(int length) public static byte[] GetRandomBytes(int length)
{ {
if (length <= 0) if (length <= 0)
{ {
@ -323,7 +370,7 @@ namespace CapyKit.Helpers
/// </summary> /// </summary>
/// <param name="validCharacters">An array of ValidCharacterCollection enumeration values representing the desired character sets.</param> /// <param name="validCharacters">An array of ValidCharacterCollection enumeration values representing the desired character sets.</param>
/// <returns>A string containing all the characters from the specified character sets.</returns> /// <returns>A string containing all the characters from the specified character sets.</returns>
private static string GetValidCharacterComposition(params ValidCharacterCollection[] validCharacters) public static string GetValidCharacterComposition(params ValidCharacterCollection[] validCharacters)
{ {
var composition = new StringBuilder(); var composition = new StringBuilder();
foreach (var c in validCharacters) foreach (var c in validCharacters)
@ -331,16 +378,16 @@ namespace CapyKit.Helpers
switch (c) switch (c)
{ {
case ValidCharacterCollection.Lowercase: case ValidCharacterCollection.Lowercase:
composition.Append(LOWER_CASE_CHARACTERS); composition.Append(SecurityHelper.LOWER_CASE_CHARACTERS);
break; break;
case ValidCharacterCollection.Uppercase: case ValidCharacterCollection.Uppercase:
composition.Append(LOWER_CASE_CHARACTERS); composition.Append(SecurityHelper.UPPER_CASE_CHARACTERS);
break; break;
case ValidCharacterCollection.Numbers: case ValidCharacterCollection.Numbers:
composition.Append(LOWER_CASE_CHARACTERS); composition.Append(SecurityHelper.NUMBER_CHARACTERS);
break; break;
case ValidCharacterCollection.Special: case ValidCharacterCollection.Special:
composition.Append(LOWER_CASE_CHARACTERS); composition.Append(SecurityHelper.SPECIAL_CHARACTERS);
break; break;
default: default:
break; break;
@ -379,16 +426,19 @@ namespace CapyKit.Helpers
/// </summary> /// </summary>
[EnumerationDescriptionAttribute(SecurityHelper.LOWER_CASE_CHARACTERS)] [EnumerationDescriptionAttribute(SecurityHelper.LOWER_CASE_CHARACTERS)]
Lowercase, Lowercase,
/// <summary> /// <summary>
/// Indicates that upper case characters should be included in the random string. /// Indicates that upper case characters should be included in the random string.
/// </summary> /// </summary>
[EnumerationDescriptionAttribute(SecurityHelper.UPPER_CASE_CHARACTERS)] [EnumerationDescriptionAttribute(SecurityHelper.UPPER_CASE_CHARACTERS)]
Uppercase, Uppercase,
/// <summary> /// <summary>
/// Indicates that numeric characters should be included in the random string. /// Indicates that numeric characters should be included in the random string.
/// </summary> /// </summary>
[EnumerationDescriptionAttribute(SecurityHelper.NUMBER_CHARACTERS)] [EnumerationDescriptionAttribute(SecurityHelper.NUMBER_CHARACTERS)]
Numbers, Numbers,
/// <summary> /// <summary>
/// Indicates that special characters should be included in the random string. /// Indicates that special characters should be included in the random string.
/// </summary> /// </summary>

View file

@ -13,7 +13,7 @@ namespace Tests.Helpers
internal class SecurityHelperTests internal class SecurityHelperTests
{ {
private const string Password = "TestPassword"; private const string Password = "TestPassword";
private static readonly byte[] Salt = SecurityHelper.GetSalt(); private static readonly byte[] Salt = SecurityHelper.GetRandomBytes(32);
[Test] [Test]
public void CompareHashedPassword_WithSamePassword_ShouldReturnTrue() public void CompareHashedPassword_WithSamePassword_ShouldReturnTrue()
@ -82,6 +82,28 @@ namespace Tests.Helpers
Assert.AreEqual(password1.Hash, password2.Hash); Assert.AreEqual(password1.Hash, password2.Hash);
} }
[Test]
public void VerifyPbkdf2_WithPersistedCredential_ReturnsExpectedResult()
{
var credential = SecurityHelper.Pbkdf2(Password, Salt);
Assert.Multiple(() =>
{
Assert.That(SecurityHelper.VerifyPbkdf2(Password, credential.Hash, credential.Salt), Is.True);
Assert.That(SecurityHelper.VerifyPbkdf2("WrongPassword", credential.Hash, credential.Salt), Is.False);
});
}
[Test]
public void HashOpaqueSecret_WithSameSecret_ReturnsStableDigest()
{
var first = SecurityHelper.HashOpaqueSecret("opaque-secret");
var second = SecurityHelper.HashOpaqueSecret("opaque-secret");
Assert.That(first, Is.EqualTo(second));
Assert.That(first, Is.Not.EqualTo("opaque-secret"));
}
//[Test] //[Test]
//public void GetPassword_WithPBKDF2Algorithm_ShouldReturnValidPasswordObject() //public void GetPassword_WithPBKDF2Algorithm_ShouldReturnValidPasswordObject()
//{ //{