From 8b373ebfe8733784babdee9dad5c91a38dfa8d11 Mon Sep 17 00:00:00 2001 From: wagesj45 Date: Mon, 20 Jul 2026 00:20:55 -0500 Subject: [PATCH] Add credential verification helpers --- CapyKit/Helpers/SecurityHelper.cs | 120 +++++++++++++++++++-------- Tests/Helpers/SecurityHelperTests.cs | 24 +++++- 2 files changed, 108 insertions(+), 36 deletions(-) diff --git a/CapyKit/Helpers/SecurityHelper.cs b/CapyKit/Helpers/SecurityHelper.cs index b0f80b5..78a0915 100644 --- a/CapyKit/Helpers/SecurityHelper.cs +++ b/CapyKit/Helpers/SecurityHelper.cs @@ -15,9 +15,6 @@ namespace CapyKit.Helpers { #region Members - /// Default size to use when generating a new salt. - private const int SALT_SIZE = 32; - /// A string of all the lower case characters. internal const string LOWER_CASE_CHARACTERS = "abcdefghijklmnopqrstuvwxyz"; @@ -33,6 +30,14 @@ namespace CapyKit.Helpers #endregion Members #region Methods + + public static bool CompareHashedPassword(Password existingPassword, string password, string salt, + params object[] args) + { + var saltBytes = Convert.FromBase64String(salt); + + return CompareHashedPassword(existingPassword, password, saltBytes, args); + } /// /// Compares an unencrypted with a stored, encrypted . @@ -47,7 +52,8 @@ namespace CapyKit.Helpers /// /// if hash comparison succeeds, if it fails. /// - public static bool CompareHashedPassword(Password existingPassword, string password, byte[] salt, params object[] args) + public static bool CompareHashedPassword(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); + } /// /// Compares an unencrypted with a stored, encrypted . @@ -70,18 +84,49 @@ namespace CapyKit.Helpers /// /// if hash comparison succeeds, if it fails. /// - 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); } + /// + /// Verifies a plaintext password against a persisted PBKDF2 hash and salt. + /// + /// The plaintext password supplied by the caller. + /// The persisted PBKDF2 hash. + /// The persisted PBKDF2 salt. + /// when the password matches the persisted hash. + 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); + } + + /// + /// Produces a deterministic SHA-256 digest for an opaque secret that must be looked up + /// without persisting the raw value. + /// + /// The raw opaque secret. + /// The Base64-encoded digest. + public static string HashOpaqueSecret(string secret) + { + ArgumentException.ThrowIfNullOrWhiteSpace(secret); + return Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(secret))); + } + /// /// Retrieves a object using the specified password and generates a random salt value. /// Then it uses that salt to call the overloaded method with the given password and @@ -98,9 +143,9 @@ namespace CapyKit.Helpers /// instance of created using any optional constructor arguments provided. /// /// - public static Password GetPassword(string password, params object[] args) + public static Password GetPassword(string password, int saltSize, params object[] args) where T : IPasswordAlgorithm { - var salt = SecurityHelper.GetSalt; + var salt = GetRandomBytes(saltSize); return GetPassword(password, salt, args); } @@ -129,14 +174,16 @@ namespace CapyKit.Helpers /// A new object with the given password and salt, as well as an instance /// of created using the provided constructor arguments. /// - public static Password GetPassword(string password, byte[] salt, params object[] args) where T : IPasswordAlgorithm + public static Password GetPassword(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 /// /// A new object containing the hashed password and a randomly generated salt. /// - 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(); } - /// Generates a random byte array that can act as a salt. - /// - /// A default length of is provided as a sane default. Larger values can be used for increased - /// entropy. - /// - /// (Optional) The desired length of the generated byte array. - /// An array of byte. - public static byte[] GetSalt(int length = SALT_SIZE) - { - return GetRandomBytes(length); - } /// Generates a new byte array of the specified length with random values. /// The desired length of the generated byte array. /// A new byte array of the specified length filled with random values. - 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 /// /// An array of ValidCharacterCollection enumeration values representing the desired character sets. /// A string containing all the characters from the specified character sets. - 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 /// [EnumerationDescriptionAttribute(SecurityHelper.LOWER_CASE_CHARACTERS)] Lowercase, + /// /// Indicates that upper case characters should be included in the random string. /// [EnumerationDescriptionAttribute(SecurityHelper.UPPER_CASE_CHARACTERS)] Uppercase, + /// /// Indicates that numeric characters should be included in the random string. /// [EnumerationDescriptionAttribute(SecurityHelper.NUMBER_CHARACTERS)] Numbers, + /// /// Indicates that special characters should be included in the random string. /// diff --git a/Tests/Helpers/SecurityHelperTests.cs b/Tests/Helpers/SecurityHelperTests.cs index 73dff40..d35fc3c 100644 --- a/Tests/Helpers/SecurityHelperTests.cs +++ b/Tests/Helpers/SecurityHelperTests.cs @@ -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() //{