using System.Buffers.Binary; using System.Security.Cryptography; using System.Text; using QRCoder; namespace PlotLine.Services; public sealed class TwoFactorService(ILogger logger) : ITwoFactorService { private const string Issuer = "PlotDirector"; private const int CodeDigits = 6; private const int TimeStepSeconds = 30; private const string Base32Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; public string GenerateSecretKey() { var bytes = RandomNumberGenerator.GetBytes(20); return ToBase32(bytes); } public string GenerateQrCodeUri(string email, string secretKey) { var issuer = Uri.EscapeDataString(Issuer); var account = Uri.EscapeDataString($"{Issuer}:{email}"); return $"otpauth://totp/{account}?secret={secretKey}&issuer={issuer}"; } public bool ValidateCode(string secretKey, string code) { return ValidateCode(secretKey, code, lastUsedTimeStep: null, out _); } public bool ValidateCode(string secretKey, string code, long? lastUsedTimeStep, out long matchedTimeStep) { matchedTimeStep = 0; var cleanCode = CleanCode(code); if (cleanCode.Length != CodeDigits || cleanCode.Any(c => !char.IsDigit(c))) { return false; } var secretBytes = FromBase32(secretKey); var currentStep = GetCurrentTimeStep(); for (var offset = -1; offset <= 1; offset++) { var step = currentStep + offset; if (lastUsedTimeStep.HasValue && step <= lastUsedTimeStep.Value) { continue; } if (GenerateTotp(secretBytes, step) == cleanCode) { matchedTimeStep = step; return true; } } return false; } public string? GenerateQrCodeDataUri(string qrCodeUri) { try { using var generator = new QRCodeGenerator(); using var data = generator.CreateQrCode(qrCodeUri, QRCodeGenerator.ECCLevel.Q); var qrCode = new PngByteQRCode(data); var bytes = qrCode.GetGraphic(8); return $"data:image/png;base64,{Convert.ToBase64String(bytes)}"; } catch (Exception ex) { logger.LogWarning(ex, "QR code generation failed for two-factor setup."); return null; } } public IReadOnlyList GenerateRecoveryCodes(int count) { var codes = new List(count); for (var i = 0; i < count; i++) { codes.Add(GenerateRecoveryCode()); } return codes; } private static long GetCurrentTimeStep() { return DateTimeOffset.UtcNow.ToUnixTimeSeconds() / TimeStepSeconds; } private static string GenerateTotp(byte[] secretBytes, long timeStep) { Span counter = stackalloc byte[8]; BinaryPrimitives.WriteInt64BigEndian(counter, timeStep); using var hmac = new HMACSHA1(secretBytes); var hash = hmac.ComputeHash(counter.ToArray()); var offset = hash[^1] & 0x0f; var binaryCode = ((hash[offset] & 0x7f) << 24) | ((hash[offset + 1] & 0xff) << 16) | ((hash[offset + 2] & 0xff) << 8) | (hash[offset + 3] & 0xff); var otp = binaryCode % 1_000_000; return otp.ToString("D6"); } private static string GenerateRecoveryCode() { const string alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; var bytes = RandomNumberGenerator.GetBytes(10); var builder = new StringBuilder(10); for (var i = 0; i < 10; i++) { builder.Append(alphabet[bytes[i] % alphabet.Length]); } return builder.ToString(); } private static string ToBase32(byte[] bytes) { var output = new StringBuilder((bytes.Length + 4) / 5 * 8); var buffer = 0; var bitsLeft = 0; foreach (var b in bytes) { buffer = (buffer << 8) | b; bitsLeft += 8; while (bitsLeft >= 5) { output.Append(Base32Alphabet[(buffer >> (bitsLeft - 5)) & 31]); bitsLeft -= 5; } } if (bitsLeft > 0) { output.Append(Base32Alphabet[(buffer << (5 - bitsLeft)) & 31]); } return output.ToString(); } private static byte[] FromBase32(string value) { var clean = value.Trim().Replace(" ", string.Empty).TrimEnd('=').ToUpperInvariant(); var bytes = new List(); var buffer = 0; var bitsLeft = 0; foreach (var c in clean) { var index = Base32Alphabet.IndexOf(c); if (index < 0) { throw new FormatException("The two-factor secret is not valid Base32."); } buffer = (buffer << 5) | index; bitsLeft += 5; if (bitsLeft >= 8) { bytes.Add((byte)((buffer >> (bitsLeft - 8)) & 255)); bitsLeft -= 8; } } return bytes.ToArray(); } private static string CleanCode(string code) { return code.Trim().Replace(" ", string.Empty).Replace("-", string.Empty); } }