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(); 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; } } }