// OpenTween - Client of Twitter
// Copyright (c) 2022 kim_upsilon (@kim_upsilon)
// All rights reserved.
//
// This file is part of OpenTween.
//
// This program is free software; you can redistribute it and/or modify it
// under the terms of the GNU General public License as published by the Free
// Software Foundation; either version 3 of the License, or (at your option)
// any later version.
//
// This program is distributed in the hope that it will be useful, but
// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General public License
// for more details.
//
// You should have received a copy of the GNU General public License along
// with this program. If not, see , or write to
// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor,
// Boston, MA 02110-1301, USA.
#nullable enable
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Runtime.Serialization;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
namespace OpenTween
{
public class ApiKey
{
private static readonly string EncryptionPrefix = "%e%";
private static readonly int SaltSize = 16;
private static readonly int KeySize = 32;
private static readonly int BlockSize = 16;
private static readonly int IterationCount = 10_000;
private static readonly HashAlgorithmName HashAlgorithm = HashAlgorithmName.SHA256;
private readonly string rawKey;
private readonly Lazy decryptLazy;
///
/// 平文の API キー
///
///
public string Value => this.decryptLazy.Value;
private ApiKey(string password, string rawKey)
{
this.rawKey = rawKey;
this.decryptLazy = new Lazy(
() => Decrypt(password, this.rawKey)
);
}
///
/// 平文の API キーを返します
///
///
/// 成功した場合は true、暗号化された API キーの復号に失敗した場合は false を返します
///
public bool TryGetValue([NotNullWhen(true)]out string? output)
{
try
{
output = this.Value;
return true;
}
catch (ApiKeyDecryptException)
{
output = null;
return false;
}
}
///
/// インスタンスを作成します
///
public static ApiKey Create(string rawKey)
=> Create(ApplicationSettings.EncryptionPassword, rawKey);
///
/// インスタンスを作成します
///
public static ApiKey Create(string password, string rawKey)
=> new(password, rawKey);
///
/// 指定された文字列を暗号化して返します
///
public static string Encrypt(string password, string plainText)
{
var salt = GenerateSalt();
var (encryptionKey, iv, macKey) = GenerateKeyAndIV(password, salt);
using var aes = CreateAes();
aes.Key = encryptionKey;
aes.IV = iv;
var plainBytes = Encoding.UTF8.GetBytes(plainText);
using var encryptor = aes.CreateEncryptor();
using var memoryStream = new MemoryStream(plainBytes);
using var cryptoStream = new CryptoStream(memoryStream, encryptor, CryptoStreamMode.Read);
using var cipherStream = new MemoryStream(capacity: plainBytes.Length + BlockSize + SaltSize);
cryptoStream.CopyTo(cipherStream);
var cipherBytes = cipherStream.ToArray();
var macBytes = GenerateMAC(plainBytes, macKey);
var saltText = Convert.ToBase64String(salt);
var cipherText = Convert.ToBase64String(cipherBytes);
var macText = Convert.ToBase64String(macBytes);
return $"{EncryptionPrefix}{saltText}%{cipherText}%{macText}";
}
///
/// 暗号化された文字列を復号します
///
///
public static string Decrypt(string password, string encryptedText)
{
// 先頭が "%e%" から始まっていない場合、APIキーは平文のまま書かれているものとして扱う
if (!encryptedText.StartsWith(EncryptionPrefix))
return encryptedText;
var splitted = encryptedText.Split('%');
if (splitted.Length != 5)
throw new ApiKeyDecryptException("暗号文のフォーマットが不正です");
byte[] salt, cipherBytes, macBytes;
try
{
// e.g. "%e%...salt...%...cipher...%...mac..."
salt = Convert.FromBase64String(splitted[2]);
cipherBytes = Convert.FromBase64String(splitted[3]);
macBytes = Convert.FromBase64String(splitted[4]);
}
catch (FormatException ex)
{
throw new ApiKeyDecryptException("不正な Base64 フォーマットです", ex);
}
var (encryptionKey, iv, macKey) = GenerateKeyAndIV(password, salt);
using var aes = CreateAes();
aes.Key = encryptionKey;
aes.IV = iv;
byte[] decryptedBytes;
try
{
using var decryptor = aes.CreateDecryptor();
using var memoryStream = new MemoryStream(cipherBytes);
using var cryptoStream = new CryptoStream(memoryStream, decryptor, CryptoStreamMode.Read);
using var decryptedStream = new MemoryStream(capacity: cipherBytes.Length);
cryptoStream.CopyTo(decryptedStream);
decryptedBytes = decryptedStream.ToArray();
}
catch (CryptographicException ex)
{
throw new ApiKeyDecryptException("API キーの復号に失敗しました", ex);
}
var macBytesExpected = GenerateMAC(decryptedBytes, macKey);
var isValid = macBytes.Length == macBytesExpected.Length &&
Enumerable.Zip(macBytes, macBytesExpected, (x, y) => x == y).All(x => x);
if (!isValid)
throw new ApiKeyDecryptException("ダイジェストが一致しません");
return Encoding.UTF8.GetString(decryptedBytes);
}
private static byte[] GenerateSalt()
{
using var random = new RNGCryptoServiceProvider();
var salt = new byte[SaltSize];
random.GetBytes(salt);
return salt;
}
private static (byte[] EncryptionKey, byte[] IV, byte[] MacKey) GenerateKeyAndIV(string password, byte[] salt)
{
using var generator = new Rfc2898DeriveBytes(password, salt, IterationCount, HashAlgorithm);
var encryptionKey = generator.GetBytes(KeySize);
var iv = generator.GetBytes(BlockSize);
var macKey = generator.GetBytes(KeySize);
return (encryptionKey, iv, macKey);
}
private static byte[] GenerateMAC(byte[] source, byte[] key)
{
using var hmac = new HMACSHA256(key);
return hmac.ComputeHash(source);
}
private static Aes CreateAes()
{
var aes = Aes.Create();
aes.KeySize = KeySize * 8;
aes.BlockSize = BlockSize * 8;
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
return aes;
}
}
public static class ApiKeyExtensions
{
#pragma warning disable SA1141
public static bool TryGetValue(this ValueTuple apiKeys, out ValueTuple decryptedKeys)
#pragma warning restore SA1141
{
var (apiKey1, apiKey2) = apiKeys;
if (apiKey1.TryGetValue(out var decrypted1) && apiKey2.TryGetValue(out var decrypted2))
{
decryptedKeys = (decrypted1, decrypted2);
return true;
}
decryptedKeys = ("", "");
return false;
}
}
[Serializable]
public class ApiKeyDecryptException : Exception
{
public ApiKeyDecryptException()
{
}
public ApiKeyDecryptException(string message)
: base(message)
{
}
public ApiKeyDecryptException(string message, Exception innerException)
: base(message, innerException)
{
}
protected ApiKeyDecryptException(SerializationInfo info, StreamingContext context)
: base(info, context)
{
}
}
}