using Microsoft.Data.Sqlite; using System.Security.Cryptography; using System.Text; namespace SimPas2_Windows.Managers { public class OtpManager { private readonly string mConnectionString; private readonly byte[] mEncryptionKey; private readonly string mCulture; private string mJpLang; public OtpManager(string databasePath, byte[] encryptionKey, string culture) { mConnectionString = $"Data Source={databasePath}"; mEncryptionKey = encryptionKey ?? throw new ArgumentNullException(nameof(encryptionKey)); mCulture = culture; mJpLang = "ja-JP"; } private bool AlreadyExists(string website, string secret, int? excludeId = null) { using (SqliteConnection conn = new SqliteConnection(mConnectionString)) { conn.Open(); SqliteCommand com = conn.CreateCommand(); com.CommandText = @" SELECT COUNT(*) FROM Otp WHERE UPPER(Website) = UPPER(@website) AND UPPER(Secret) = UPPER(@secret)"; com.Parameters.AddWithValue("@website", website); com.Parameters.AddWithValue("@secret", secret); if (excludeId.HasValue) { com.CommandText += " AND Id != @excludeId"; com.Parameters.AddWithValue("@excludeId", excludeId.Value); } return Convert.ToInt32(com.ExecuteScalar()) > 0; } } public void AddOtp(string website, string secret, string issuer, string algorithm, int duration, int digits, string note) { if (string.IsNullOrWhiteSpace(website) || string.IsNullOrWhiteSpace(secret) || string.IsNullOrWhiteSpace(issuer)) { string err = mCulture == mJpLang ? "ウェブサイト、秘密鍵及び、発行者を御入力下さい。" : "Please fill in the website, secret, and issuer."; throw new ArgumentException(err); } string encryptedSecret = EncryptSecret(secret); if (AlreadyExists(website, encryptedSecret)) { string err = mCulture == mJpLang ? $"ウェブサイト「{website}」向け秘密鍵は既に存在します。" : $"An OTP with the website and secret for '{website}' already exists."; throw new ArgumentException(err); } using (SqliteConnection conn = new SqliteConnection(mConnectionString)) { conn.Open(); SqliteCommand com = conn.CreateCommand(); com.CommandText = @" INSERT INTO Otp (Website, Secret, Issuer, Algorithm, Duration, Digits, Note) VALUES ($website, $secret, $issuer, $algorithm, $duration, $digits, $note)"; com.Parameters.AddWithValue("$website", website); com.Parameters.AddWithValue("$secret", encryptedSecret); com.Parameters.AddWithValue("$issuer", issuer); com.Parameters.AddWithValue("$algorithm", algorithm); com.Parameters.AddWithValue("$duration", duration); com.Parameters.AddWithValue("$digits", digits); com.Parameters.AddWithValue("$note", string.IsNullOrEmpty(note) ? DBNull.Value : note); com.ExecuteNonQuery(); } } public bool EditOtp(int id, string website, string secret, string issuer, string algorithm, int duration, int digits, string note) { if (string.IsNullOrWhiteSpace(website) || string.IsNullOrWhiteSpace(secret) || string.IsNullOrWhiteSpace(issuer)) { string err = mCulture == mJpLang ? "ウェブサイト、秘密鍵及び、発行者を御入力下さい。" : "Please fill in the website, secret, and issuer."; throw new ArgumentException(err); } string encryptedSecret = EncryptSecret(secret); if (AlreadyExists(website, encryptedSecret, id)) { string err = mCulture == mJpLang ? $"ウェブサイト「{website}」向け秘密鍵は既に存在します。" : $"An OTP with the website and secret for '{website}' already exists."; throw new ArgumentException(err); } using (SqliteConnection conn = new SqliteConnection(mConnectionString)) { conn.Open(); SqliteCommand com = conn.CreateCommand(); com.CommandText = @" UPDATE Otp SET Website = $website, Secret = $secret, Issuer = $issuer, Algorithm = $algorithm, Duration = $duration, Digits = $digits, Note = $note WHERE Id = $id"; com.Parameters.AddWithValue("$id", id); com.Parameters.AddWithValue("$website", website); com.Parameters.AddWithValue("$secret", encryptedSecret); com.Parameters.AddWithValue("$issuer", issuer); com.Parameters.AddWithValue("$algorithm", algorithm); com.Parameters.AddWithValue("$duration", duration); com.Parameters.AddWithValue("$digits", digits); com.Parameters.AddWithValue("$note", string.IsNullOrEmpty(note) ? DBNull.Value : note); return com.ExecuteNonQuery() > 0; } } public bool DeleteOtp(int id) { using (SqliteConnection conn = new SqliteConnection(mConnectionString)) { conn.Open(); SqliteCommand com = conn.CreateCommand(); com.CommandText = "DELETE FROM Otp WHERE Id = $id"; com.Parameters.AddWithValue("$id", id); return com.ExecuteNonQuery() > 0; } } public List<(int Id, string Website, string Secret, string Issuer, string Algorithm, int Duration, int Digits, string Note)> GetAll(string keyword = "") { var otps = new List<(int, string, string, string, string, int, int, string)>(); using (SqliteConnection conn = new SqliteConnection(mConnectionString)) { conn.Open(); SqliteCommand com = conn.CreateCommand(); if (string.IsNullOrWhiteSpace(keyword)) { com.CommandText = @" SELECT Id, Website, Secret, Issuer, Algorithm, Duration, Digits, Note FROM Otp ORDER BY Website ASC"; } else { com.CommandText = @" SELECT Id, Website, Secret, Issuer, Algorithm, Duration, Digits, Note FROM Otp WHERE Website LIKE @keyword OR Issuer LIKE @keyword ORDER BY Website ASC"; com.Parameters.AddWithValue("@keyword", $"%{keyword}%"); } using (SqliteDataReader reader = com.ExecuteReader()) { while (reader.Read()) { string encryptedSecret = reader.GetString(2); string decryptedSecret = DecryptSecret(encryptedSecret); otps.Add(( reader.GetInt32(0), reader.GetString(1), decryptedSecret, reader.GetString(3), reader.GetString(4), reader.GetInt32(5), reader.GetInt32(6), reader.IsDBNull(7) ? string.Empty : reader.GetString(7) )); } } } return otps; } public (string Code, string Error) GenerateTotp(string secret, int digits, string algorithm, int duration = 30) { try { var (sec, error) = ExtractSecret(secret); if (!string.IsNullOrEmpty(error)) { return ("", error); } long currentTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); ulong counter = (ulong)(currentTime / duration); uint totp = GenerateTotpCode(sec, counter, digits, algorithm); return (totp.ToString($"D{digits}"), ""); } catch (Exception ex) { return ("", $"TOTP generation error: {ex.Message}"); } } private (byte[]? Secret, string Error) ExtractSecret(string otpauthUrl) { if (string.IsNullOrEmpty(otpauthUrl)) { string err = mCulture == mJpLang ? "OTP Authを御入力下さい。" : "Please fill in the OTP Auth."; throw new ArgumentException(err); } if (!otpauthUrl.StartsWith("otpauth://totp/", StringComparison.OrdinalIgnoreCase)) { try { byte[] secretDecoded = Base32Decode(otpauthUrl); return (secretDecoded, ""); } catch (Exception ex) { string err = mCulture == mJpLang ? $"TOTP秘密鍵を復号化に失敗:{ex.Message}" : $"Failed to decode TOTP secret: {ex.Message}"; return (null, err); } } int secretStart = otpauthUrl.IndexOf("secret=", StringComparison.OrdinalIgnoreCase); if (secretStart == -1) { string err = mCulture == mJpLang ? "TOTP秘密鍵をURLの中に見つけられませんでした。" : "TOTP secret not found in URL"; return (null, err); } secretStart += 7; int secretEnd = otpauthUrl.IndexOf('&', secretStart); if (secretEnd == -1) { secretEnd = otpauthUrl.Length; } if (secretEnd <= secretStart) { string err = mCulture == mJpLang ? "不正なTOTP秘密枠" : "Invalid TOTP secret range"; return (null, err); } string secretEncoded = otpauthUrl.Substring(secretStart, secretEnd - secretStart); try { byte[] secretDecoded = Base32Decode(secretEncoded); return (secretDecoded, ""); } catch (Exception ex) { string err = mCulture == mJpLang ? $"TOTP秘密鍵を復号化に失敗:{ex.Message}" : $"Failed to decode TOTP secret: {ex.Message}"; return (null, err); } } public (string Secret, string Issuer, string Algorithm, int Duration, int Digits) ParseOtpAuthUrl(string otpAuthUrl) { if (string.IsNullOrEmpty(otpAuthUrl) || !otpAuthUrl.StartsWith("otpauth://totp/", StringComparison.OrdinalIgnoreCase)) { string err = mCulture == mJpLang ? "不正なOTP Auth URL" : "Invalid OTP Auth URL"; throw new ArgumentException(err); } string issuer = ""; string algorithm = "SHA1"; int duration = 30; int digits = 6; int labelStart = "otpauth://totp/".Length; int labelEnd = otpAuthUrl.IndexOf('?', labelStart); if (labelEnd != -1) { issuer = otpAuthUrl.Substring(labelStart, labelEnd - labelStart); if (issuer.Contains(':')) { issuer = issuer.Substring(0, issuer.IndexOf(':')); } } int secretStart = otpAuthUrl.IndexOf("secret=", StringComparison.OrdinalIgnoreCase); if (secretStart == -1) { string err = mCulture == mJpLang ? "TOTP秘密鍵をURLの中に見つけられませんでした。" : "TOTP secret not found in URL"; throw new ArgumentException(err); } secretStart += 7; int secretEnd = otpAuthUrl.IndexOf('&', secretStart); if (secretEnd == -1) { secretEnd = otpAuthUrl.Length; } string secret = otpAuthUrl.Substring(secretStart, secretEnd - secretStart); var queryParams = otpAuthUrl.Substring(labelEnd + 1).Split('&').Select(p => p.Split('=')).ToDictionary(p => p[0].ToLower(), p => p.Length > 1 ? p[1] : ""); if (queryParams.ContainsKey("issuer")) { issuer = queryParams["issuer"]; } if (queryParams.ContainsKey("algorithm")) { algorithm = queryParams["algorithm"].ToUpper(); if (algorithm != "SHA1" && algorithm != "SHA256" && algorithm != "SHA512") { algorithm = "SHA1"; } } if (queryParams.ContainsKey("period") && int.TryParse(queryParams["period"], out int parsedPeriod)) { duration = parsedPeriod; } if (queryParams.ContainsKey("digits") && int.TryParse(queryParams["digits"], out int parsedDigits)) { digits = parsedDigits; } return (secret, issuer, algorithm, duration, digits); } private uint GenerateTotpCode(byte[] secret, ulong counter, int digits, string algorithm) { byte[] counterBytes = BitConverter.GetBytes(counter); if (BitConverter.IsLittleEndian) { Array.Reverse(counterBytes); } using (HMAC hmac = algorithm.ToUpper() switch { "SHA256" => new HMACSHA256(secret), "SHA512" => new HMACSHA512(secret), _ => new HMACSHA1(secret), }) { byte[] hash = hmac.ComputeHash(counterBytes); int offset = hash[hash.Length - 1] & 0x0F; uint truncatedHash = (uint)(hash[offset] & 0x7F) << 24 | (uint)(hash[offset + 1] & 0xFF) << 16 | (uint)(hash[offset + 2] & 0xFF) << 8 | (uint)(hash[offset + 3] & 0xFF); return truncatedHash % (uint)Math.Pow(10, digits); } } private byte[] Base32Decode(string base32) { const string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; if (string.IsNullOrEmpty(base32)) { string err = mCulture == mJpLang ? "Base32文字はnullや空です。" : "Base32 string cannot be null or empty"; throw new ArgumentException(err); } // Remove spaces, hyphens, and convert to uppercase base32 = base32.Trim().Replace(" ", "").Replace("-", "").ToUpper(); // Count padding characters int padding = 0; for (int i = base32.Length - 1; i >= 0 && base32[i] == '='; i--) { padding++; } // Remove padding for processing string cleanBase32 = base32.Substring(0, base32.Length - padding); if (cleanBase32.Any(c => !alphabet.Contains(c))) { string err = mCulture == mJpLang ? "Base32文字は不正文字が含みます。" : "Base32 string contains invalid characters"; throw new ArgumentException(err); } // Calculate output length int byteCount = cleanBase32.Length * 5 / 8; if (byteCount == 0) { return Array.Empty(); } byte[] buffer = new byte[byteCount]; int bufferIndex = 0; int bits = 0; int bitCount = 0; foreach (char c in cleanBase32) { int value = alphabet.IndexOf(c); if (value < 0) { string err = mCulture == mJpLang ? "Base32文字は不正文字が含みます。" : "Base32 string contains invalid characters"; throw new ArgumentException(err); } bits = bits << 5 | value; bitCount += 5; if (bitCount >= 8) { buffer[bufferIndex++] = (byte)(bits >> bitCount - 8); bitCount -= 8; } } // Resize to actual bytes written if (bufferIndex < byteCount) { Array.Resize(ref buffer, bufferIndex); } return buffer; } private string EncryptSecret(string secret) { using (Aes aes = Aes.Create()) { aes.Key = mEncryptionKey; aes.GenerateIV(); byte[] iv = aes.IV; using (ICryptoTransform encryptor = aes.CreateEncryptor(aes.Key, iv)) { byte[] plainBytes = Encoding.UTF8.GetBytes(secret); byte[] encryptedBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length); byte[] result = new byte[iv.Length + encryptedBytes.Length]; Buffer.BlockCopy(iv, 0, result, 0, iv.Length); Buffer.BlockCopy(encryptedBytes, 0, result, iv.Length, encryptedBytes.Length); return Convert.ToBase64String(result); } } } private string DecryptSecret(string encryptedSecret) { byte[] combined = Convert.FromBase64String(encryptedSecret); byte[] iv = new byte[16]; byte[] encryptedBytes = new byte[combined.Length - iv.Length]; Buffer.BlockCopy(combined, 0, iv, 0, iv.Length); Buffer.BlockCopy(combined, iv.Length, encryptedBytes, 0, encryptedBytes.Length); using (Aes aes = Aes.Create()) { aes.Key = mEncryptionKey; aes.IV = iv; using (ICryptoTransform decryptor = aes.CreateDecryptor(aes.Key, aes.IV)) { byte[] decryptedBytes = decryptor.TransformFinalBlock(encryptedBytes, 0, encryptedBytes.Length); return Encoding.UTF8.GetString(decryptedBytes); } } } } }