shoc-backend/SeaHaven.Services/Helpers/PasswordResetCodeSecrets.cs
Alexandre Brandizzi 77a10e38ca fix(auth): cap reset abuse per account, key code hashes, send reset email off the request path
- Forgot Password is limited to 3 codes an hour and 10 a day per email, and
  an account gets 10 failed code checks a day across every code it is sent,
  so new client addresses and new codes no longer buy more guesses. Refused
  requests answer exactly like accepted ones.
- The reset email is queued to a background sender, and unregistered
  addresses store a row no code can match, so both paths do the same work
  and return without waiting on the mail provider. Each request also clears
  expired codes.
- Code hashes are HMAC-SHA256 under a key derived with HKDF from the JWT
  signing secret; rows in the previous unkeyed format stop matching.
- Email and code are read only from the JSON body.
2026-09-25 13:00:03 -03:00

61 lines
2.5 KiB
C#

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
/// 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.
/// </summary>
public static class PasswordResetCodeSecrets
{
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);
}
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();
}
/// <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)
{
ArgumentNullException.ThrowIfNull(key);
ArgumentNullException.ThrowIfNull(salt);
ArgumentNullException.ThrowIfNull(code);
return Convert.ToHexString(HMACSHA256.HashData(key, Encoding.UTF8.GetBytes(salt + ":" + code))).ToLowerInvariant();
}
public static bool Matches(byte[] key, string salt, string candidate, string expectedHash)
{
if (string.IsNullOrEmpty(salt) || string.IsNullOrEmpty(expectedHash))
return false;
var actual = Encoding.ASCII.GetBytes(Hash(key, salt, candidate.Trim()));
var expected = Encoding.ASCII.GetBytes(expectedHash);
return CryptographicOperations.FixedTimeEquals(actual, expected);
}
}
}