This commit is contained in:
2026-01-21 03:07:35 +09:00
commit 5c7516f60f
19 changed files with 15472 additions and 0 deletions

479
Managers/OtpManager.cs Normal file
View File

@@ -0,0 +1,479 @@
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);
}
}
}
}
}