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
/// <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>
internal const string LOWER_CASE_CHARACTERS = "abcdefghijklmnopqrstuvwxyz";
@ -33,6 +30,14 @@ namespace CapyKit.Helpers
#endregion Members
#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>
/// Compares an unencrypted <paramref name="password"/> with a stored, encrypted <paramref name="existingPassword"/>.
@ -47,7 +52,8 @@ namespace CapyKit.Helpers
/// <returns>
/// <see langword="true"/> if hash comparison succeeds, <see langword="false"/> if it fails.
/// </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)
.GetMethod("GetPassword", new Type[] { typeof(string), typeof(byte[]), typeof(object[]) })
@ -56,6 +62,14 @@ namespace CapyKit.Helpers
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>
/// Compares an unencrypted <paramref name="password"/> with a stored, encrypted <paramref name="existingPassword"/>.
@ -70,18 +84,49 @@ namespace CapyKit.Helpers
/// <returns>
/// <see langword="true"/> if hash comparison succeeds, <see langword="false"/> if it fails.
/// </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 providedPassword = typeof(SecurityHelper)
.GetMethod("GetPassword", new Type[] {typeof(string), typeof(byte[]), typeof(object[])})
.GetMethod("GetPassword", new Type[] { typeof(string), typeof(byte[]), typeof(object[]) })
?.MakeGenericMethod(algorithmType)
?.Invoke(null, new object[] { password, salt, args });
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>
/// 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
@ -98,9 +143,9 @@ namespace CapyKit.Helpers
/// instance of <typeparamref name="T"/> created using any optional constructor arguments provided.
/// </returns>
/// <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);
}
@ -129,14 +174,16 @@ namespace CapyKit.Helpers
/// 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.
/// </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 argTypes = args.Select(arg => arg.GetType()).ToArray();
var algorithmConstructor = typeof(T).GetConstructor(argTypes);
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[]
{
typeof(T).Name,
@ -149,7 +196,8 @@ namespace CapyKit.Helpers
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[]
{
typeof(T).Name,
@ -198,9 +246,9 @@ namespace CapyKit.Helpers
/// <returns>
/// A new <see cref="Password"/> object containing the hashed password and a randomly generated salt.
/// </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);
return pwd;
@ -217,9 +265,15 @@ namespace CapyKit.Helpers
{
if (validCharacters.Length == 0)
{
CapyEventReporter.EmitEvent(EventLevel.Warning, "No valid characters were provided, so all valid caharacters will be assumed.");
validCharacters = new[] { ValidCharacterCollection.Lowercase, ValidCharacterCollection.Uppercase, ValidCharacterCollection.Numbers, ValidCharacterCollection.Special };
CapyEventReporter.EmitEvent(EventLevel.Warning,
"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);
}
@ -235,6 +289,7 @@ namespace CapyKit.Helpers
{
return CompareStrings(first, second);
}
return CompareStrings(first.Trim(), second.Trim());
}
@ -259,9 +314,12 @@ namespace CapyKit.Helpers
public static string GetRandomString(int length, params ValidCharacterCollection[] validChars)
{
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 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())
{
@ -286,24 +344,13 @@ namespace CapyKit.Helpers
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>
/// <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>
private static byte[] GetRandomBytes(int length)
public static byte[] GetRandomBytes(int length)
{
if(length <= 0)
if (length <= 0)
{
CapyEventReporter.EmitEvent(EventLevel.Error, "Length must be greater than 0.");
return GetRandomBytes(16);
@ -323,7 +370,7 @@ namespace CapyKit.Helpers
/// </summary>
/// <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>
private static string GetValidCharacterComposition(params ValidCharacterCollection[] validCharacters)
public static string GetValidCharacterComposition(params ValidCharacterCollection[] validCharacters)
{
var composition = new StringBuilder();
foreach (var c in validCharacters)
@ -331,16 +378,16 @@ namespace CapyKit.Helpers
switch (c)
{
case ValidCharacterCollection.Lowercase:
composition.Append(LOWER_CASE_CHARACTERS);
composition.Append(SecurityHelper.LOWER_CASE_CHARACTERS);
break;
case ValidCharacterCollection.Uppercase:
composition.Append(LOWER_CASE_CHARACTERS);
composition.Append(SecurityHelper.UPPER_CASE_CHARACTERS);
break;
case ValidCharacterCollection.Numbers:
composition.Append(LOWER_CASE_CHARACTERS);
composition.Append(SecurityHelper.NUMBER_CHARACTERS);
break;
case ValidCharacterCollection.Special:
composition.Append(LOWER_CASE_CHARACTERS);
composition.Append(SecurityHelper.SPECIAL_CHARACTERS);
break;
default:
break;
@ -379,16 +426,19 @@ namespace CapyKit.Helpers
/// </summary>
[EnumerationDescriptionAttribute(SecurityHelper.LOWER_CASE_CHARACTERS)]
Lowercase,
/// <summary>
/// Indicates that upper case characters should be included in the random string.
/// </summary>
[EnumerationDescriptionAttribute(SecurityHelper.UPPER_CASE_CHARACTERS)]
Uppercase,
/// <summary>
/// Indicates that numeric characters should be included in the random string.
/// </summary>
[EnumerationDescriptionAttribute(SecurityHelper.NUMBER_CHARACTERS)]
Numbers,
/// <summary>
/// Indicates that special characters should be included in the random string.
/// </summary>

View file

@ -13,7 +13,7 @@ namespace Tests.Helpers
internal class SecurityHelperTests
{
private const string Password = "TestPassword";
private static readonly byte[] Salt = SecurityHelper.GetSalt();
private static readonly byte[] Salt = SecurityHelper.GetRandomBytes(32);
[Test]
public void CompareHashedPassword_WithSamePassword_ShouldReturnTrue()
@ -82,6 +82,28 @@ namespace Tests.Helpers
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]
//public void GetPassword_WithPBKDF2Algorithm_ShouldReturnValidPasswordObject()
//{