This repository has been archived on 2026-05-26. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
SimPas2-Windows/Managers/OtpManager.cs
2026-01-21 03:07:35 +09:00

480 lines
16 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>();
}
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);
}
}
}
}
}