480 lines
16 KiB
C#
480 lines
16 KiB
C#
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);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|