Generalize password hash comparison

This commit is contained in:
Jordan Wages 2026-07-20 00:30:37 -05:00
commit 8405d09992
2 changed files with 61 additions and 29 deletions

View file

@ -33,10 +33,9 @@ namespace CapyKit.Helpers
public static bool CompareHashedPassword<T>(Password existingPassword, string password, string salt,
params object[] args)
where T : IPasswordAlgorithm
{
var saltBytes = Convert.FromBase64String(salt);
return CompareHashedPassword<T>(existingPassword, password, saltBytes, args);
return CompareHashedPassword<T>(Convert.ToBase64String(existingPassword.Hash), password, salt, args);
}
/// <summary>
@ -54,13 +53,61 @@ namespace CapyKit.Helpers
/// </returns>
public static bool CompareHashedPassword<T>(Password existingPassword, string password, byte[] salt,
params object[] args)
where T : IPasswordAlgorithm
{
var providedPassword = typeof(SecurityHelper)
.GetMethod("GetPassword", new Type[] { typeof(string), typeof(byte[]), typeof(object[]) })
?.MakeGenericMethod(typeof(T))
?.Invoke(null, new object[] { password, salt, args });
return CompareHashedPassword<T>(existingPassword.Hash, password, salt, args);
}
return existingPassword.Equals(providedPassword);
/// <summary>
/// Compares a plaintext password with a persisted Base64-encoded hash and salt.
/// </summary>
/// <typeparam name="T">The password hashing algorithm.</typeparam>
/// <param name="storedHash">The Base64-encoded persisted hash.</param>
/// <param name="password">The plaintext password to verify.</param>
/// <param name="salt">The Base64-encoded persisted salt.</param>
/// <param name="args">Arguments used to construct the password algorithm.</param>
/// <returns><see langword="true"/> when the password matches the persisted hash.</returns>
public static bool CompareHashedPassword<T>(string storedHash, string password, string salt,
params object[] args)
where T : IPasswordAlgorithm
{
if (string.IsNullOrWhiteSpace(storedHash) || string.IsNullOrEmpty(password) || string.IsNullOrWhiteSpace(salt))
{
return false;
}
try
{
return CompareHashedPassword<T>(Convert.FromBase64String(storedHash), password,
Convert.FromBase64String(salt), args);
}
catch (FormatException)
{
return false;
}
}
/// <summary>
/// Compares a plaintext password with a persisted hash and salt.
/// </summary>
/// <typeparam name="T">The password hashing algorithm.</typeparam>
/// <param name="storedHash">The persisted hash.</param>
/// <param name="password">The plaintext password to verify.</param>
/// <param name="salt">The persisted salt.</param>
/// <param name="args">Arguments used to construct the password algorithm.</param>
/// <returns><see langword="true"/> when the password matches the persisted hash.</returns>
public static bool CompareHashedPassword<T>(byte[] storedHash, string password, byte[] salt,
params object[] args)
where T : IPasswordAlgorithm
{
if (storedHash == null || storedHash.Length == 0 || string.IsNullOrEmpty(password) || salt == null || salt.Length == 0)
{
return false;
}
var candidatePassword = GetPassword<T>(password, salt, args);
return candidatePassword is not null && candidatePassword.Hash.Length == storedHash.Length &&
CryptographicOperations.FixedTimeEquals(storedHash, candidatePassword.Hash);
}
public static bool CompareHashedPassword(Password existingPassword, string password, string salt,
@ -97,24 +144,6 @@ namespace CapyKit.Helpers
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.

View file

@ -83,14 +83,17 @@ namespace Tests.Helpers
}
[Test]
public void VerifyPbkdf2_WithPersistedCredential_ReturnsExpectedResult()
public void CompareHashedPassword_WithPersistedHashAndSalt_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);
Assert.That(SecurityHelper.CompareHashedPassword<Pbkdf2Algorithm>(credential.Hash, Password, credential.Salt), Is.True);
Assert.That(SecurityHelper.CompareHashedPassword<Pbkdf2Algorithm>(credential.Hash, "WrongPassword", credential.Salt), Is.False);
Assert.That(SecurityHelper.CompareHashedPassword<Pbkdf2Algorithm>(
Convert.ToBase64String(credential.Hash), Password, Convert.ToBase64String(credential.Salt)), Is.True);
Assert.That(SecurityHelper.CompareHashedPassword<Pbkdf2Algorithm>("not-base64", Password, "not-base64"), Is.False);
});
}