歡迎光臨
每天分享高質量文章

ASP.NET Core 資料加解密的一些坑

ASP.NET Core 給我們提供了自帶的Data Protection機制,用於敏感資料加解密,帶來方便的同時也有一些限制可能引發問題,這幾天我就被狠狠爆了一把

我的場景

我的部落格系統有個傳送郵件通知的功能,因此需要配置一個郵箱賬號,讓程式去用該賬號像管理員或使用者傳送郵件。這就牽涉到如何安全儲存賬戶密碼的問題了。作為有節操的程式員,我們當然不能像國內眾多平臺一樣儲存明文密碼到資料庫。在這個場景裡,我們也沒法用HASH儲存密碼,因為發郵件是系統後臺自己完成的,不會要求使用者輸入密碼進行HASH運算之後與資料庫儲存的HASH對比。因此,我首先想到的就是用AES這樣的對稱加密演演算法,在資料庫裡儲存加密後的密文,由程式根據Key去解密,然後使用該賬號傳送郵件。

不想重覆造輪子

在設計一個功能之前,我通常會先查閱資料,看看是否有框架自帶的功能可以完成需求。於是,ASP.NET Core自帶的Data Protection引起了我的註意。

冗長的官方檔案大家可以自己去看,這裡我做一下總結:

使用Data Protection API的好處在於:

  1. 淘汰傳統的MachineKey。

  2. 無需自己去設計加密演演算法,直接使用框架提供的,由專業的微軟保證安全的演演算法即可。

  3. 無需自己管理金鑰,預設情況下框架會自動生成以及選擇對應的儲存方式。

  4. 金鑰預設情況每90天自動更替一次。

  5. 程式設計方式簡單,通常情況下無需深入瞭解原理即可完成需求。

  6. 保留靈活性和拓展性,允許自定義演演算法、金鑰儲存等步驟。

有關Data Protection的詳細介紹,可以看官方檔案:

https://docs.microsoft.com/en-us/aspnet/core/security/data-protection/introduction?view=aspnetcore-2.2

Data Protection 預設用的演演算法就是AES,可以滿足我的需要。

加解密過程

框架幫我們隱藏複雜的演演算法過程之後,我們只要簡單3部,就能完成加解密。

通常的實踐是:在Startup裡新增DataProtection服務

public void ConfigureServices(IServiceCollection services)

{

    services.AddDataProtection();

    // …

}

然後建立一個類似這樣的Service供系統其他地方加解密資料。

public class EncryptionService

{

    private readonly IDataProtectionProvider _dataProtectionProvider;

    private const string Key = “cxz92k13md8f981hu6y7alkc”;

    public EncryptionService(IDataProtectionProvider dataProtectionProvider)

    {

        _dataProtectionProvider = dataProtectionProvider;

    }

    public string Encrypt(string input)

    {

        var protector = _dataProtectionProvider.CreateProtector(Key);

        return protector.Protect(input);

    }

    public string Decrypt(string cipherText)

    {

        var protector = _dataProtectionProvider.CreateProtector(Key);

        return protector.Unprotect(cipherText);

    }

}

我用該方法,加密了郵箱密碼,並儲存到資料庫。然後更改了對應的程式碼從資料中成功解密,併在自己機器上除錯完成傳送郵件的功能,沒有問題。於是我部署到了生產環境……

坑來了

生產環境解密資料庫中的密文時發生了異常

System.Security.Cryptography.CryptographicException: The key {bd424a84-5faa-4b97-8cd9-6bea01f052cd} was not found in the key ring.

經過研究,這是因為,ASP.NET Core在不同機器上執行的時候,會生成不同的Key用來加密資料,而我資料庫裡的密文是用開發機的Key加密的,和伺服器的Key不一樣因此嘗試解密的時候,找不到加密用的Key,就產生了這個異常。

ASP.NET Core 可以將Key儲存在登錄檔、使用者profile、Azure KeyVault、Azure 儲存賬戶、檔案系統等多種位置。

在Azure App Service下,Key被儲存在了%HOME%\ASP.NET\DataProtection-Keys檔案夾裡。這個檔案夾會非常神奇的自動同步到App Service的其他Instance下。

有興趣的猿可以在Kudu工具裡看到這個檔案夾:

因此要解決不同環境Key不一致的問題,只需要找一個一致的儲存位置即可。但這並不能解決問題!因為預設情況下,每90天會重新生成一個新的Key,這樣資料庫裡的密文如果不更新的話,又會失效。

另外,ASP.NET Core表單使用的AntiForgeryToken也使用這套機制加密。因此如果你自己部署了多個instance的伺服器(而不是用App Service去彈性擴充),就會導致每臺伺服器的key不同,使用者提交表單會驗證失敗。

解決方法

雖然我們可以做到用統一的位置儲存Key,也能指定自動掃清週期,但我並不建議這樣做。因為這套機制只適用於加密短時效的資料,並不是針對被持久化到資料庫裡的資料而設計的。所以在這種場景下,我們還是得自己寫一個加解密的服務。

先(很不要臉的)從微軟官方檔案裡拷一對AES加解密函式:

加密

private static byte[] EncryptStringToBytes_Aes(string plainText, byte[] key, byte[] iv)

{

    if (plainText == null || plainText.Length <= 0)

        throw new ArgumentNullException(nameof(plainText));

    if (key == null || key.Length <= 0)

        throw new ArgumentNullException(nameof(key));

    if (iv == null || iv.Length <= 0)

        throw new ArgumentNullException(nameof(iv));

    byte[] encrypted;

    using (var aesAlg = Aes.Create())

    {

        aesAlg.Key = key;

        aesAlg.IV = iv;

        var encryptor = aesAlg.CreateEncryptor(aesAlg.Key, aesAlg.IV);

        using (var msEncrypt = new MemoryStream())

        {

            using (var csEncrypt = new CryptoStream(msEncrypt, encryptor, CryptoStreamMode.Write))

            {

                using (var swEncrypt = new StreamWriter(csEncrypt))

                {

                    swEncrypt.Write(plainText);

                }

                encrypted = msEncrypt.ToArray();

            }

        }

    }

    return encrypted;

}

解密

private static string DecryptStringFromBytes_Aes(byte[] cipherText, byte[] key, byte[] iv)

{

    if (cipherText == null || cipherText.Length <= 0)

        throw new ArgumentNullException(nameof(cipherText));

    if (key == null || key.Length <= 0)

        throw new ArgumentNullException(nameof(key));

    if (iv == null || iv.Length <= 0)

        throw new ArgumentNullException(nameof(iv));

    string plaintext;

    using (var aesAlg = Aes.Create())

    {

        aesAlg.Key = key;

        aesAlg.IV = iv;

        var decryptor = aesAlg.CreateDecryptor(aesAlg.Key, aesAlg.IV);

        using (var msDecrypt = new MemoryStream(cipherText))

        {

            using (var csDecrypt = new CryptoStream(msDecrypt, decryptor, CryptoStreamMode.Read))

            {

                using (var srDecrypt = new StreamReader(csDecrypt))

                {

                    plaintext = srDecrypt.ReadToEnd();

                }

            }

        }

    }

    return plaintext;

}

定義一個EncryptionService

為了方便使用,加密結果我喜歡輸出為string型別

public class EncryptionService

{

    private readonly KeyInfo _keyInfo;

    public EncryptionService(KeyInfo keyInfo = null)

    {

        _keyInfo = keyInfo;

    }

    public string Encrypt(string input)

    {

        var enc = EncryptStringToBytes_Aes(input, _keyInfo.Key, _keyInfo.Iv);

        return Convert.ToBase64String(enc);

    }

    public string Decrypt(string cipherText)

    {

        var cipherBytes = Convert.FromBase64String(cipherText);

        return DecryptStringFromBytes_Aes(cipherBytes, _keyInfo.Key, _keyInfo.Iv);

    }

    // 微軟那兩個加解密函式…

}

其中KeyInfo設計成一個單獨的類,用來靈活的讓使用者選擇賦值byte[]陣列還是string型別的Key以及初始向量(IV)

public class KeyInfo

{

    public byte[] Key { get; }

    public byte[] Iv { get; }

    public string KeyString => Convert.ToBase64String(Key);

    public string IVString => Convert.ToBase64String(Iv);

    public KeyInfo()

    {

        using (var myAes = Aes.Create())

        {

            Key = myAes.Key;

            Iv = myAes.IV;

        }

    }

    public KeyInfo(string key, string iv)

    {

        Key = Convert.FromBase64String(key);

        Iv = Convert.FromBase64String(iv);

    }

    public KeyInfo(byte[] key, byte[] iv)

    {

        Key = key;

        Iv = iv;

    }

}

註冊到DI容器

services.AddTransient(ec => new EncryptionService(new KeyInfo(“45BLO2yoJkvBwz99kBEMlNkxvL40vUSGaqr/WBu3+Vg=“, “Ou3fn+I9SVicGWMLkFEgZQ==“)));

其中的Key和IV可以透過KeyInfo的無參建構式獲得。自己儲存下來以後,就可以一直用這一對Key了,保證之後的加解密資料都是一致的。

使用方式

private readonly EncryptionService _encryptionService;

public HomeController(EncryptionService encryptionService)

{

    _encryptionService = encryptionService;

}

public IActionResult Index()

{

    var str = “Hello”;

    var enc = _encryptionService.Encrypt(str);

    var dec = _encryptionService.Decrypt(enc);

    return Content($”str: {str}, enc: {enc}, dec: {dec}”);

}

總結

ASP.NET Core 自帶的Data Protection API非常安全,使用方便,也比較靈活。但要註意Key儲存以及定時掃清,只適用短時效的加密。對於長時間儲存的固定密文,可以自己實現一個加解密服務。

完整的案例程式碼參見我的GitHub:

https://github.com/EdiWang/DotNet-Samples/tree/master/AspNet-AES-Non-DPAPI

 

    贊(0)

    分享創造快樂