2026-09-25 12:21:29 -03:00
|
|
|
using System.Globalization;
|
|
|
|
|
using System.Security.Cryptography;
|
|
|
|
|
using System.Text;
|
|
|
|
|
|
|
|
|
|
namespace SeaHaven.Services.Helpers
|
|
|
|
|
{
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Generation and hashing for emailed password reset codes. The raw code exists
|
2026-09-25 13:00:03 -03:00
|
|
|
/// only in memory and in the email sent to the account holder. Hashes are keyed
|
|
|
|
|
/// with a server-side key, so a copy of the database alone cannot be used to
|
|
|
|
|
/// brute-force the six-digit codes offline.
|
2026-09-25 12:21:29 -03:00
|
|
|
/// </summary>
|
|
|
|
|
public static class PasswordResetCodeSecrets
|
|
|
|
|
{
|
2026-09-25 13:00:03 -03:00
|
|
|
private static readonly byte[] KeyInfo = Encoding.UTF8.GetBytes("password-reset-code-v1");
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Derives the code-hashing key from an existing server secret with HKDF, so no
|
|
|
|
|
/// new secret is needed and the derived key is useless for anything else.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public static byte[] DeriveKey(string serverSecret)
|
|
|
|
|
{
|
|
|
|
|
ArgumentException.ThrowIfNullOrEmpty(serverSecret);
|
|
|
|
|
return HKDF.DeriveKey(HashAlgorithmName.SHA256, Encoding.UTF8.GetBytes(serverSecret), 32, Array.Empty<byte>(), KeyInfo);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-25 12:21:29 -03:00
|
|
|
public static string NewCode()
|
|
|
|
|
{
|
|
|
|
|
return RandomNumberGenerator.GetInt32(0, 1_000_000).ToString("D6", CultureInfo.InvariantCulture);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static string NewSalt()
|
|
|
|
|
{
|
|
|
|
|
return Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-25 13:00:03 -03:00
|
|
|
/// <summary>A value shaped like a hash that no code can match.</summary>
|
|
|
|
|
public static string NewUnmatchableHash()
|
|
|
|
|
{
|
|
|
|
|
return Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static string Hash(byte[] key, string salt, string code)
|
2026-09-25 12:21:29 -03:00
|
|
|
{
|
2026-09-25 13:00:03 -03:00
|
|
|
ArgumentNullException.ThrowIfNull(key);
|
2026-09-25 12:21:29 -03:00
|
|
|
ArgumentNullException.ThrowIfNull(salt);
|
|
|
|
|
ArgumentNullException.ThrowIfNull(code);
|
2026-09-25 13:00:03 -03:00
|
|
|
return Convert.ToHexString(HMACSHA256.HashData(key, Encoding.UTF8.GetBytes(salt + ":" + code))).ToLowerInvariant();
|
2026-09-25 12:21:29 -03:00
|
|
|
}
|
|
|
|
|
|
2026-09-25 13:00:03 -03:00
|
|
|
public static bool Matches(byte[] key, string salt, string candidate, string expectedHash)
|
2026-09-25 12:21:29 -03:00
|
|
|
{
|
|
|
|
|
if (string.IsNullOrEmpty(salt) || string.IsNullOrEmpty(expectedHash))
|
|
|
|
|
return false;
|
|
|
|
|
|
2026-09-25 13:00:03 -03:00
|
|
|
var actual = Encoding.ASCII.GetBytes(Hash(key, salt, candidate.Trim()));
|
2026-09-25 12:21:29 -03:00
|
|
|
var expected = Encoding.ASCII.GetBytes(expectedHash);
|
|
|
|
|
return CryptographicOperations.FixedTimeEquals(actual, expected);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|