159 lines
4.7 KiB
C#
159 lines
4.7 KiB
C#
|
|
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;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|