AuthorBuddy/Services/TotpHelper.cs

159 lines
4.7 KiB
C#
Raw Normal View History

2026-05-21 11:04:42 +02:00
using System.Security.Cryptography;
namespace AuthorBuddy.Web.Services;
public static class TotpHelper
{
private const int TotpSize = 6;
private const int TotpStepSeconds = 30;
private const int WindowSize = 1;
public static string GenerateSecret()
{
var secret = RandomNumberGenerator.GetBytes(20);
return Base32Encode(secret);
}
public static string GetProvisioningUri(string secret, string username, string issuer = "AuthorBuddy")
{
return $"otpauth://totp/{Uri.EscapeDataString(issuer)}:{Uri.EscapeDataString(username)}" +
$"?secret={secret}&issuer={Uri.EscapeDataString(issuer)}&algorithm=SHA1&digits={TotpSize}&period={TotpStepSeconds}";
}
public static bool VerifyCode(string secret, string code)
{
if (string.IsNullOrWhiteSpace(code) || code.Length != TotpSize) return false;
if (!int.TryParse(code, out _)) return false;
var secretBytes = Base32Decode(secret);
var counter = GetCurrentCounter();
for (int i = -WindowSize; i <= WindowSize; i++)
{
var expected = GenerateTotp(secretBytes, counter + i);
if (expected == code) return true;
}
return false;
}
private static long GetCurrentCounter()
{
var unix = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
return unix / TotpStepSeconds;
}
private static string GenerateTotp(byte[] secret, long counter)
{
var counterBytes = BitConverter.GetBytes(counter);
if (BitConverter.IsLittleEndian) Array.Reverse(counterBytes);
byte[] hash;
using (var hmac = new HMACSHA1(secret))
{
hash = hmac.ComputeHash(counterBytes);
}
int offset = hash[^1] & 0xf;
int binary = ((hash[offset] & 0x7f) << 24) |
((hash[offset + 1] & 0xff) << 16) |
((hash[offset + 2] & 0xff) << 8) |
(hash[offset + 3] & 0xff);
int otp = binary % (int)Math.Pow(10, TotpSize);
return otp.ToString($"D{TotpSize}");
}
private static string Base32Encode(byte[] data)
{
const string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
int bitCount = 0;
int currentByte = 0;
var result = new System.Text.StringBuilder();
foreach (var b in data)
{
currentByte = (currentByte << 8) | b;
bitCount += 8;
while (bitCount >= 5)
{
bitCount -= 5;
int index = (currentByte >> bitCount) & 0x1f;
result.Append(alphabet[index]);
}
}
if (bitCount > 0)
{
int index = (currentByte << (5 - bitCount)) & 0x1f;
result.Append(alphabet[index]);
}
return result.ToString();
}
private static byte[] Base32Decode(string base32)
{
const string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
base32 = base32.Trim().ToUpperInvariant()
.Replace('0', 'O').Replace('1', 'I').Replace('8', 'B');
int bitCount = 0;
int currentByte = 0;
var bytes = new System.Collections.Generic.List<byte>();
foreach (var c in base32)
{
int index = alphabet.IndexOf(c);
if (index < 0) continue;
currentByte = (currentByte << 5) | index;
bitCount += 5;
if (bitCount >= 8)
{
bitCount -= 8;
bytes.Add((byte)((currentByte >> bitCount) & 0xff));
}
}
return [.. bytes];
}
public static string HashPassword(string password)
{
var salt = RandomNumberGenerator.GetBytes(16);
var hash = Rfc2898DeriveBytes.Pbkdf2(
password,
salt,
100_000,
HashAlgorithmName.SHA256,
32);
return $"{Convert.ToBase64String(salt)}.{Convert.ToBase64String(hash)}";
}
public static bool VerifyPassword(string password, string hashedPassword)
{
if (string.IsNullOrWhiteSpace(hashedPassword)) return false;
var parts = hashedPassword.Split('.');
if (parts.Length != 2) return false;
try
{
var salt = Convert.FromBase64String(parts[0]);
var storedHash = Convert.FromBase64String(parts[1]);
var computedHash = Rfc2898DeriveBytes.Pbkdf2(
password,
salt,
100_000,
HashAlgorithmName.SHA256,
32);
return CryptographicOperations.FixedTimeEquals(storedHash, computedHash);
}
catch
{
return false;
}
}
}