mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 09:33:13 +00:00
- 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.
61 lines
2.5 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|