Procházet zdrojové kódy

AES 加密添加校验位;继续写 Config

龙腾猫跃 před 4 měsíci
rodič
revize
f64685b177

+ 85 - 19
Shared/General/Config.cs

@@ -1,35 +1,83 @@
-namespace MeloongCore;
+using System.Collections.Concurrent;
+using System.Security.Cryptography;
+
+namespace MeloongCore;
 
 public interface IConfigProvider {
     /// <summary>
-    /// 写入加密后的原始数据。
+    /// 将原始数据存入缓存。
     /// </summary>
-    void Write(string key, JToken value);
-    JToken? Read(string key, JToken? defaultValue = null);
+    void Set(string key, string value, bool encrypted = false);
+    string? Read(string key, string? defaultValue = null, bool encrypted = false);
     bool HasValue(string key);
     void Remove(string key);
     void Save();
+    void ClearCache();
 }
-public class JsonConfigProvider(string filePath) : IConfigProvider {
-    public readonly Lazy<JObject> Content = new(() => {
+
+public class JsonConfigProvider : IConfigProvider {
+    private readonly string filePath;
+    public readonly ConcurrentDictionary<string, string?> DecryptedContent = new();
+
+    public JsonConfigProvider(string filePath) {
+        this.filePath = filePath;
+        ResetContent();
+    }
+
+    /// <summary>
+    /// 读取到的原始内容。
+    /// </summary>
+    public Lazy<JObject> Content { get; private set; }
+    private void ResetContent() {
+        Content = new(() => {
+            try {
+                return FileUtils.Exists(filePath) ? ((JObject?) FileUtils.ReadAsJson(filePath) ?? []) : [];
+            } catch (Exception ex) {
+                Logger.Error(ex, $"读取配置文件失败:{filePath}", LogBehavior.Toast);
+                return [];
+            }
+        });
+    }
+
+    public void Set(string key, string? value, bool encrypted = false) {
+        if (encrypted) {
+            Content.Value[key] = CryptographyUtils.AesEncrypt(value); // UNDONE: 改为识别码
+            DecryptedContent[key] = value;
+        } else {
+            Content.Value[key] = value;
+        }
+    }
+    public string? Read(string key, string? defaultValue = null, bool encrypted = false) {
+        if (!Content.Value.ContainsKey(key)) return defaultValue;
+        if (encrypted && DecryptedContent.TryGetValue(key, out var cachedValue)) return cachedValue; // 读取缓存中已经解密的值
+        // 读取值
+        string value = Content.Value[key]!.ToString();
+        if (!encrypted) return value;
+        // 解密读取的数据
         try {
-            return FileUtils.Exists(filePath) ? ((JObject?) FileUtils.ReadAsJson(filePath) ?? []) : [];
-        } catch (Exception ex) {
-            Logger.Error(ex, $"读取配置文件失败:{filePath}", LogBehavior.Toast);
-            return [];
+            return DecryptedContent.GetOrAdd(key, _ => CryptographyUtils.AesDecrypt(value));
+        } catch (CryptographicException ex) {
+            Logger.Error(ex, $"解密设置失败,该设置将被重置:{key}", LogBehavior.Toast);
+            Remove(key);
+            Save();
+            return defaultValue;
         }
-    });
-    public void Write(string key, JToken value)
-        => Content.Value[key] = value;
-    public JToken? Read(string key, JToken? defaultValue = null)
-        => Content.Value.TryGetValue(key, out var value) ? value : defaultValue;
+    }
+
     public bool HasValue(string key)
         => Content.Value.ContainsKey(key);
-    public void Remove(string key)
-        => Content.Value.Remove(key);
+    public void Remove(string key) {
+        Content.Value.Remove(key);
+        DecryptedContent.TryRemove(key, out _);
+    }
     public void Save()
         => FileUtils.Write(filePath, Content.Value.ToString(Formatting.Indented));
+    public void ClearCache() {
+        ResetContent();
+        DecryptedContent.Clear();
+    }
 }
+
 public static class Configs {
     public static readonly JsonConfigProvider AppData = new(Path.Combine(Paths.AppDataThenName, "config.json"));
 
@@ -41,14 +89,32 @@ public static class Configs {
 }
 
 public class ConfigEntry<T>(string key, T? defaultValue, IConfigProvider? defaultProvider = null, bool encrypted = false) {
+    private IConfigProvider GetProvider(IConfigProvider? provider)
+        => provider ?? defaultProvider ?? Configs.AppData;
+
     /// <summary>
     /// 当设置项的值实际被改变时触发,参数为新值。
     /// </summary>
     public event Action<T?>? OnChanged;
 
 
-    private IConfigProvider GetProvider(IConfigProvider? provider) 
-        => provider ?? defaultProvider ?? Configs.AppData;
+    /*public T? Value {
+        get {
+            return GetProvider(null).Read(key, JToken.FromObject(defaultValue))?.ToObject<T>();
+        }
+        set {
+            var provider = GetProvider(null);
+            var oldValue = Value;
+            if (!Equals(oldValue, value)) {
+                provider.Write(key, JToken.FromObject(value));
+                OnChanged?.Invoke(value);
+            }
+        }
+    }
+
+    public T? GetValue(IConfigProvider? provider = null) {
+        return GetProvider(provider).Read(key, JToken.FromObject(defaultValue))?.ToObject<T>();
+    }*/
 
     public bool HasValue(IConfigProvider? provider = null)
         => GetProvider(provider).HasValue(key);

+ 37 - 22
Shared/Utils/CryptographyUtils.cs

@@ -65,58 +65,73 @@ public static class CryptographyUtils {
     /// <summary>
     /// 使用 AES-256-CBC 对称加密字符串。
     /// </summary>
-    /// <returns>加密后的 Base64 字符串。</returns>
-    public static string AesEncrypt(string sourceString, string key = "EncryptKey") {
-        byte[] iv = new byte[16];
+    /// <returns>加密后的 Base64 字符串。若输入为空字符串或 null,则原样返回空字符串或 null。</returns>
+    public static string? AesEncrypt(string? sourceString, string key = "EncryptKey") {
+        if (string.IsNullOrEmpty(sourceString)) return sourceString; // 原样返回空字符串或 null
+        byte[] iv = new byte[AesIvSize];
         using var rng = RandomNumberGenerator.Create();
         rng.GetBytes(iv);
         byte[] cipherBytes;
         using var sha256 = SHA256.Create();
+        byte[] aesKey = sha256.ComputeHash(Encoding.UTF8.GetBytes(key));
+        byte[] hmacKey = sha256.ComputeHash(Encoding.UTF8.GetBytes("AES-HMAC|" + key));
         using Aes aes = Aes.Create();
         aes.Mode = CipherMode.CBC;
         aes.Padding = PaddingMode.PKCS7;
-        using ICryptoTransform encryptor = aes.CreateEncryptor(sha256.ComputeHash(Encoding.UTF8.GetBytes(key)), iv);
-        using var ms = new MemoryStream();
-        using var cs = new CryptoStream(ms, encryptor, CryptoStreamMode.Write);
+        using ICryptoTransform encryptor = aes.CreateEncryptor(aesKey, iv);
         byte[] sourceBytes = Encoding.UTF8.GetBytes(sourceString);
-        cs.Write(sourceBytes, 0, sourceBytes.Length);
-        cs.FlushFinalBlock();
-        cipherBytes = ms.ToArray();
-        byte[] encryptedBytes = new byte[1 + iv.Length + cipherBytes.Length];
+        cipherBytes = encryptor.TransformFinalBlock(sourceBytes, 0, sourceBytes.Length);
+        // 格式:版本 [1 字节] | IV [16 字节] | 密文 [N 字节] | 校验 [4 字节]
+        byte[] encryptedBytes = new byte[1 + iv.Length + cipherBytes.Length + AesCheckSize];
         encryptedBytes[0] = AesDataVersion;
         Array.Copy(iv, 0, encryptedBytes, 1, iv.Length);
         Array.Copy(cipherBytes, 0, encryptedBytes, 1 + iv.Length, cipherBytes.Length);
+        using var hmac = new HMACSHA256(hmacKey);
+        byte[] checkBytes = hmac.ComputeHash(encryptedBytes, 0, 1 + iv.Length + cipherBytes.Length);
+        Array.Copy(checkBytes, 0, encryptedBytes, 1 + iv.Length + cipherBytes.Length, AesCheckSize);
         return Convert.ToBase64String(encryptedBytes);
     }
 
     /// <summary>
     /// 使用 AES-256-CBC 对称解密字符串。
-    /// 如果密钥错误或数据格式有误,则抛出 <see cref="CryptographicException"/>。
+    /// 如果输入数据有误,则确保会抛出 <see cref="CryptographicException"/>。
     /// </summary>
-    public static string AesDecrypt(string encryptedString, string key = "EncryptKey") {
+    /// <returns>解密后的字符串。若输入为空字符串或 null,则原样返回空字符串或 null。</returns>
+    public static string? AesDecrypt(string? encryptedString, string key = "EncryptKey") {
+        if (string.IsNullOrEmpty(encryptedString)) return encryptedString; // 原样返回空字符串或 null
         byte[] encryptedBytes;
         try {
             encryptedBytes = Convert.FromBase64String(encryptedString);
         } catch (FormatException ex) {
             throw new CryptographicException($"数据不是一个有效的 Base64 字符串({encryptedString})", ex);
         }
-        int cipherSize = encryptedBytes.Length - 1 - 16;
-        if (encryptedBytes.Length < 1 + 16 + 16 || encryptedBytes[0] != AesDataVersion || cipherSize % 16 != 0)
+        if (encryptedBytes.Length == 0) throw new CryptographicException($"对称加密数据长度有误({encryptedString})");
+        int cipherSize = encryptedBytes.Length - 1 - AesIvSize - AesCheckSize;
+        if (encryptedBytes[0] != AesDataVersion || cipherSize < 16 || cipherSize % 16 != 0)
             throw new CryptographicException($"对称加密数据长度有误({encryptedString})");
-        byte[] iv = new byte[16];
-        Array.Copy(encryptedBytes, 1, iv, 0, iv.Length);
         using var sha256 = SHA256.Create();
+        byte[] hmacKey = sha256.ComputeHash(Encoding.UTF8.GetBytes("AES-HMAC|" + key));
+        using var hmac = new HMACSHA256(hmacKey);
+        byte[] checkBytes = hmac.ComputeHash(encryptedBytes, 0, 1 + AesIvSize + cipherSize);
+        int checkOffset = 1 + AesIvSize + cipherSize;
+        int checkDiff = 0;
+        for (int i = 0; i < AesCheckSize; i++)
+            checkDiff |= encryptedBytes[checkOffset + i] ^ checkBytes[i];
+        if (checkDiff != 0) throw new CryptographicException($"对称加密数据校验失败({encryptedString})");
+        byte[] aesKey = sha256.ComputeHash(Encoding.UTF8.GetBytes(key));
+        byte[] iv = new byte[AesIvSize];
+        Array.Copy(encryptedBytes, 1, iv, 0, iv.Length);
         using Aes aes = Aes.Create();
         aes.Mode = CipherMode.CBC;
         aes.Padding = PaddingMode.PKCS7;
-        using ICryptoTransform decryptor = aes.CreateDecryptor(sha256.ComputeHash(Encoding.UTF8.GetBytes(key)), iv);
-        using var ms = new MemoryStream(encryptedBytes, 1 + iv.Length, cipherSize);
-        using var cs = new CryptoStream(ms, decryptor, CryptoStreamMode.Read);
-        using var output = new MemoryStream();
-        cs.CopyTo(output);
-        return Encoding.UTF8.GetString(output.ToArray());
+        using ICryptoTransform decryptor = aes.CreateDecryptor(aesKey, iv);
+        byte[] output = decryptor.TransformFinalBlock(encryptedBytes, 1 + iv.Length, cipherSize);
+        return Encoding.UTF8.GetString(output);
     }
 
+    private const int AesIvSize = 16;
+    private const int AesCheckSize = 4;
+
     #endregion
 
     #region ECDSA 非对称加密签名

+ 36 - 0
Tests/General/ConfigTest.cs

@@ -0,0 +1,36 @@
+namespace MeloongCore.Tests;
+
+public class ConfigTest : TestWithFolder {
+
+    [Test]
+    public async Task JsonConfigProvider_EncryptedReadWrite() {
+        var filePath = Path.Combine(tempFolder, "config.json");
+        var provider = new JsonConfigProvider(filePath);
+
+        provider.Set("token", "plain token", encrypted: true);
+        provider.Save();
+
+        await Assert.That(FileUtils.ReadAsString(filePath).Contains("plain token")).IsFalse();
+        await Assert.That(provider.Read("token", encrypted: true)).IsEqualTo("plain token");
+        await Assert.That(provider.DecryptedContent.TryGetValue("token", out var decrypted) && decrypted == "plain token").IsTrue();
+    }
+
+    [Test]
+    public async Task JsonConfigProvider_ClearCache() {
+        var filePath = Path.Combine(tempFolder, "config.json");
+        var provider = new JsonConfigProvider(filePath);
+
+        provider.Set("token", "old token", encrypted: true);
+        provider.Save();
+        await Assert.That(provider.Read("token", encrypted: true)).IsEqualTo("old token");
+
+        var secondProvider = new JsonConfigProvider(filePath);
+        secondProvider.Set("token", "new token", encrypted: true);
+        secondProvider.Save();
+        provider.ClearCache();
+
+        await Assert.That(provider.DecryptedContent.IsEmpty).IsTrue();
+        await Assert.That(provider.Read("token", encrypted: true)).IsEqualTo("new token");
+    }
+
+}

+ 9 - 1
Tests/Utils/CryptographyUtilsTest.cs

@@ -11,7 +11,8 @@ public class CryptographyUtilsTest : TestBase {
     [Arguments("中文内容 Test 123 !@#$%^&*()")]
     public async Task 对称加密_往返(string sourceString) {
         string encryptedString = CryptographyUtils.AesEncrypt(sourceString, "PCL-Key-中文");
-        await Assert.That(encryptedString != sourceString).IsTrue();
+        if (!string.IsNullOrEmpty(sourceString))
+            await Assert.That(encryptedString != sourceString).IsTrue();
         await Assert.That(CryptographyUtils.AesDecrypt(encryptedString, "PCL-Key-中文")).IsEqualTo(sourceString);
     }
 
@@ -26,6 +27,13 @@ public class CryptographyUtilsTest : TestBase {
         Assert.Throws<CryptographicException>(() => CryptographyUtils.AesDecrypt(Convert.ToBase64String(encryptedBytes), "key"));
     }
 
+    [Test]
+    public void AesDecrypt_TamperedIvFails() {
+        byte[] encryptedBytes = Convert.FromBase64String(CryptographyUtils.AesEncrypt("source", "key"));
+        encryptedBytes[1] ^= 1;
+        Assert.Throws<CryptographicException>(() => CryptographyUtils.AesDecrypt(Convert.ToBase64String(encryptedBytes), "key"));
+    }
+
     [Test]
     public void 对称加密_错误密钥失败() {
         Assert.Throws<CryptographicException>(() => CryptographyUtils.AesDecrypt(CryptographyUtils.AesEncrypt("source", "key"), "bad key"));