ASP.NET Core 6框架揭秘實例演示[19]:數據加解密與哈希

數據保護(Data Protection)框架旨在解決數據在傳輸與持久化存儲過程中的一致性(Integrity)和機密性(confidentiality)問題,前者用於檢驗接收到的數據是否經過篡改,後者通過對原始的數據進行加密以避免真實的內容被人窺視。數據保護是支撐ASP.NET身份認證的一個重要的基礎框架,同時也可以作為獨立的框架供我們使用。(本篇提供的實例已經匯總到《ASP.NET Core 6框架揭秘-實例演示版》)

[S1301]數據的加解密(源程式碼
[S1302]Purpose字元串一致性(源程式碼
[S1303]設置加密內容的有效期(源程式碼
[S1304]撤銷加密密鑰(單個密鑰)(源程式碼
[S1305]撤銷加密密鑰(所有密鑰)(源程式碼
[S1306]瞬時加解密(源程式碼
[S1307]密鑰哈希(源程式碼

[S1301]數據的加解密

對提供的原始數據(字元串或者二進位數組)進行加密是數據保護框架體提供的基本功能,接下來我們利用一個簡單的控制台程式來演示一下加解密如何實現。數據的加解密均由IDataProtector對象來完成,而該對象由IDataProtectionProvider(不是IDataProtectorProvider)對象來提供,所以在大部分應用場景中針對數據的加密和解密只涉及這兩個對象。有了依賴注入的加持,我們也不需要了解這兩個介面的具體實現類型,只需要在利用注入的IDataProtectionProvider對象來提供對應的IDataProtector對象,並利用後者完成加解密的工作。

上述的這兩個介面定義在 「Microsoft.AspNetCore.DataProtection.Abstractions」這個NuGet包中,它們的默認實現類型以及其他核心類型則承載於NuGet包 「Microsoft.AspNetCore.DataProtection」中,所以我們需要為演示程式添加針對這個NuGet包的引用。由於需要使用到依賴注入框架,我們需要添加針對「Microsoft.Extensions.DependencyInjection」的引用。必要的NuGet包引用添加完成之後,我們編寫了如下的演示程式。

using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.DependencyInjection;
using System.Diagnostics;

var originalPayload = Guid.NewGuid().ToString();
var protectedPayload = Encrypt("foo", originalPayload);
var unprotectedPayload = Decrypt("foo", protectedPayload);
Debug.Assert(originalPayload == unprotectedPayload);

static string Encrypt(string purpose, string originalPayload) => GetDataProtector(purpose).Protect(originalPayload);
static string Decrypt(string purpose, string protectedPayload) => GetDataProtector(purpose).Unprotect(protectedPayload);

static IDataProtector GetDataProtector(string purpose)
{
    var services = new ServiceCollection();
    services.AddDataProtection();
    return services
        .BuildServiceProvider()
        .GetRequiredService<IDataProtectionProvider>()
        .CreateProtector(purpose);
}

如上面的程式碼片段所示,我們將數據的加密和解密操作分別定義在Encrypt和Decrypt方法中,它們使用IDataProtector對象由GetDataProtector方法來提供。在GetDataProtector方法中,我們創建了一個ServiceCollection對象,並調用AddDataProtection擴展方法註冊了數據保護框架的基礎服務。我們最終利用構建的IServiceProvider對象來提供所需的IDataProtectionProvider對象。IDataProtectionProvider介面的CreateProtector方法定義了一個字元串類型名為「purpose」的參數。從字面上來講,該參數表示加密的「目的(Purpose)」,它在整個數據保護模型中起到了「秘鑰隔離」的作用,我們在本書後續內容中將其稱為「Purpose字元串」。

Encrypt和Decrypt方法來利用指定的Purpose字元串作為參數調用GetDataProtector方法得到對應的IDataProtector對象之後,分別調用了該對象的Protect和Unprotect方法完成了針對給定文本內容的加密和解密。我們使用一個GUID轉換的字元串作為待加密的數據,並使用「foo」作為Purpose字元串調用Encrypt方法對它進行了加密,最後採用相同的Purpose字元串調用Decrypt方法對加密內容進行解密。

前面的演示實例通過調用IServiceProvider對象的GetRequiredService<T>擴展方法得到所需的IDataProtectionProvider對象,該對象也可以按照如下的形式調用GetDataProtectionProvider擴展方法來獲取。IServiceProvider介面還定義了如下這個GetDataProtector擴展方法直接返回IDataProtector對象。

...
static IDataProtector GetDataProtector(string purpose)
{
    var services = new ServiceCollection();
    services.AddDataProtection();
    return services
        .BuildServiceProvider()
        .GetDataProtectionProvider()
        .CreateProtector(purpose);
}

或者

...
static IDataProtector GetDataProtector(string purpose)
{
    var services = new ServiceCollection();
    services.AddDataProtection();
    return services
        .BuildServiceProvider()
        .GetDataProtector (purpose);
}

除了利用依賴注入框架,我們也可以按照如下的方法利用靜態類型DataProtectorProvider(定義在「Mcrosoft.AspNetCore.DataProtection.Extensions」NuGet包中)來創建IDataProtectionProvider對象。該類型提供了若干用於創建IDataProtector對象的Create方法重載,我們選擇的重載傳入的參數為當前應用的名稱。

...
static IDataProtector GetDataProtector(string purpose) => DataProtectionProvider.Create("App").CreateProtector(purpose);

[S1302]Purpose字元串一致性

前面我們說到參與同一份數據加解密的兩個IDataProtector對象必須具有一致的Purpose字元串,我們現在就來驗證這一點。如下面的程式碼片段所示,我們在調用Decrypt方法進行解密的時候將Purpose字元串從「foo」替換成「bar」。

...
var originalPayload = Guid.NewGuid().ToString();
var protectedPayload = Encrypt ("foo", originalPayload);
var unprotectedPayload = Decrypt ("bar", protectedPayload);
Debug.Assert(originalPayload == unprotectedPayload);
...

當我們調用IDataProtector對象的Unprotect方法對指定內容進行解密時,由於當前Purpose字元串與待解密內容採用的Purpose字元串不符,會直接拋出如圖1所示的CryptographicException異常。

image

圖1 Purpose字元串不一致導致的異常

[S1303]設置加密內容的有效期

我們知道不論採用的何種加密演算法,採用的秘鑰位數有多長,如果算力資源或者時間充足,解密都能成功。但是黑客具有的算力資源總歸是有限的,如果能夠在秘鑰能推算出來之前就已經無效了,那麼我們採用的加密方式就是安全的。針對有效時間的加解密通過ITimeLimitedDataProtector對象來完成,這個介面都定義在「Mcrosoft.AspNetCore.DataProtection.Extensions」 這個NuGet包中。為了使用這個對象,我們將演示程式改寫成如下的形式。

using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.DependencyInjection;
using System.Diagnostics;

var originalPayload = Guid.NewGuid().ToString();
var protectedPayload = Encrypt("foo", originalPayload, TimeSpan.FromSeconds(5));

var unprotectedPayload = Decrypt("foo", protectedPayload);
Debug.Assert(originalPayload == unprotectedPayload);

await Task.Delay(5000);
Decrypt("foo", protectedPayload);

static string Encrypt(string purpose, string originalPayload, TimeSpan timeout)
=> GetDataProtector(purpose)
.Protect(originalPayload, DateTimeOffset.UtcNow.Add(timeout));
static string Decrypt(string purpose, string protectedPayload)
    => GetDataProtector(purpose).Unprotect(protectedPayload, out _);

static ITimeLimitedDataProtector GetDataProtector(string purpose)
{
    var services = new ServiceCollection();
    services.AddDataProtection();
    return services
        .BuildServiceProvider()
        .GetDataProtector(purpose)
        .ToTimeLimitedDataProtector();
}

我們讓GetDataProtector方法返回一個ITimeLimitedDataProtector對象,它通過IDataProtector對象的ToTimeLimitedDataProtector擴展方法「轉化」而成。用於加密的Encrypt方法添加了一個表示過期時間的timeout參數(類型為TimeSpan),由於ITimeLimitedDataProtector的Protect方法中表示過期時間的參數類型為DateTimeOffset,所以我們基於當前時間和指定的過期時間(TimeSpan)將這個過期時間點計算出來。ITimeLimitedDataProtector介面用於解密的Unprotect方法具有一個表示過期日期的輸出參數。

在演示程式中,我們調用Encrypt方法對數據進行加密時將過期時間設置為5秒。對於加密後的內容,我們採用相同的方式對它進行了兩次解密,第一個發生在5秒內,第二次則發生在5秒後。程式運行後,第一次解密成功,第二次拋出如圖13-3所示的CryptographicException異常。

image
圖2 加密數據過期導致的解密異常

[S1304]撤銷加密密鑰(單個密鑰)

在如下的演示程式中,我們創建了ServiceCollection對象並在調用AddDataProtection擴展方法註冊了數據保護框架的核心服務。在利用構建的IServiceProvider對象得到IDataProtector對象之後,我們利用它對指定的文本進行加密。在此之後,我們將加密採用的密鑰撤銷掉。

using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.DataProtection.KeyManagement;
using Microsoft.AspNetCore.DataProtection.KeyManagement.Internal;
using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();
services.AddDataProtection();
var sericeProvider = services.BuildServiceProvider();
var protector = sericeProvider.GetDataProtector("foobar");
var originalPayload = Guid.NewGuid().ToString();
var protectedPayload = protector.Protect(originalPayload);

var keyRingProvider = sericeProvider.GetRequiredService<IKeyRingProvider>();
var KeyRing = keyRingProvider.GetCurrentKeyRing();
var keyManager = sericeProvider.GetRequiredService<IKeyManager>();
keyManager.RevokeKey(KeyRing.DefaultKeyId);
protector.Unprotect(protectedPayload);

具體來說,我們利用IServiceProvider對象提供的IKeyRingProvider對象得到對應的IKeyRing對象,該對象的DefaultKeyId屬性代表默認使用的密鑰ID,我們撤銷的也這是這個ID代表的密鑰。,我們藉助於依賴注入容器得到IKeyManager對象,並將此密鑰ID作為參數調用其RevokeKey方法。在密鑰撤銷之後,我們利用同一個IDataProtector對加密內容進行解密,此時程式會拋出如圖3所示的CryptographicException異常。

image
圖3 秘鑰被撤銷導致的解密異常

[S1305]撤銷加密密鑰(所有密鑰)

除了調用IKeyManager的RevokeKey方法撤銷某個指定的密鑰之外,我們還可以按照如下的方式調用它的RevokeAllKeys方法撤銷所有密鑰。如果我們覺得目前的所有密鑰均不安全,可以調用這個方法。我們在調用該方法的時候需要指定一個撤銷的時間和原因(可選)。

using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.DataProtection.KeyManagement;
using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();
services.AddDataProtection();
var sericeProvider = services.BuildServiceProvider();
var protector = sericeProvider.GetDataProtector("foobar");
var originalPayload = Guid.NewGuid().ToString();
var protectedPayload = protector.Protect(originalPayload);

var keyManager = sericeProvider.GetRequiredService<IKeyManager>();
keyManager.RevokeAllKeys(revocationDate: DateTimeOffset.UtcNow, reason: "No reason");
protector.Unprotect(protectedPayload);

[S1306]瞬時加解密

在某些應用場景中,針對數據的加解密只在一個限定的上下文中進行(比如當前應用的生命周期內),這種場景適用一種被稱為「瞬時(Transient或者Ephemeral)加解密」的方式。這種加解密方式會使用到EphemeralDataProtectionProvider類型,該類型同樣實現了ITimeLimitedDataProtector介面。如果我們利用它提供的IDataProtector對象對一段二進位內容進行加密,密文只能通過它自身提供的IDataProtector對象才能解開。

如下面的程式碼片段所示,我們定義了一個CreateEphemeralDataProtectionProvider方法用來創建上述的這個對象。我們在調用ServiceCollection對象的AddDataProtection擴展方法並得到返回的IDataProtectionBuilder之後,我們調用了該對象的UseEphemeralDataProtectionProvider擴展方法完成針對EphemeralDataProtectionProvider的服務註冊,所以我們最終得到的IDataProtectionProvider對象的類型就是EphemeralDataProtectionProvider。

using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.DependencyInjection;
using System.Diagnostics;

var originalPayload = Guid.NewGuid().ToString();
var dataProtectionProvider = CreateEphemeralDataProtectionProvider();
var protector = dataProtectionProvider.CreateProtector("foobar");
var protectedPayload = protector.Protect(originalPayload);

protector = dataProtectionProvider.CreateProtector("foobar");
Debug.Assert(originalPayload == protector.Unprotect(protectedPayload));

protector = CreateEphemeralDataProtectionProvider().CreateProtector("foobar");
protector.Unprotect(protectedPayload);

static IDataProtectionProvider CreateEphemeralDataProtectionProvider()
{
    var services = new ServiceCollection();
    services.AddDataProtection().UseEphemeralDataProtectionProvider();
    return services.BuildServiceProvider().GetRequiredService<IDataProtectionProvider>();
}

在利用EphemeralDataProtectionProvider提供的IDataProtector對象對一段文本加密後,我們對密文實施了兩次解密。第一次採用的IDataProtector對象通過同一個EphemeralDataProtectionProvider對象提供的,第二個則則不是。該演示程式運行之後,第一次解密順利完成,第二次則拋出了如圖4所示的CryptographicException異常。

image
圖4 利用EphemeralDataProtectionProvider提供「瞬時」加解密

[S1307]密鑰哈希

用戶密碼作為機密性最高的資訊是不能以明文形式存儲的,我們一般會存儲密碼的哈希值。雖然哈希的非對稱性確保不能直接通過哈希值得到被哈希的原始內容,但是在強大的算力面前已經不足以提供我們期望的安全保障。針對密鑰的保護,目前最安全的哈希方式應該是PBKDF2(Password-Based Key Derivation Function 2)。PBKDF2是一種基於密碼的Key Derivation(採用某種演算法根據指定的密碼或者主鍵生成一個密鑰)函數,它採用偽隨機函數以任意指定長度導出密鑰。它目前是RSA實驗室公鑰加密標準(PKCS:Public-Key Cryptography Standards)序列的一部分。PBKDF2提高安全係數主要採用「添加隨機鹽(Salt)」和「多次哈希」這兩種手段。如果希望對PBKDF2具有深入的了解,可以參閱官方規範文檔(//tools.ietf.org/html/rfc2898#section-5.2)。

我們在可以利用「Microsoft.AspNetCore.Cryptography.KeyDerivation」這個NuGet包提供的API來對密碼進行哈希。這是一個完全獨立的類庫,與上面介紹的以IDataProtector對象為核心的數據保護框架沒有關係。基於PBKDF2的密碼哈希可以直接調用KeyDerivation類型的如下這個靜態方法Pbkdf2來完成。

public static class KeyDerivation
{
public static byte[] Pbkdf2(string password, byte[] salt, KeyDerivationPrf prf,
    int iterationCount, int numBytesRequested);
}

public enum KeyDerivationPrf
{
    HMACSHA1,
    HMACSHA256,
    HMACSHA512
}

PBKDF2並沒有限制使用某種固定的加密演算法。在調用上面這個Pbkdf2方法的時候,我們可以利用prf參數指定採用的偽隨機演算法(PRF:Pseudo-random Function)。這是一個KeyDerivationPrf類型的枚舉,三個枚舉項對應的哈希演算法分別為SHA-1、SHA-256和SHA-512。Pbkdf2方法的其他參數分別表示待哈希的密碼、隨機鹽、迭代次數(次數越大、安全係數越大)和最終生成哈希值的位元組數。

using Microsoft.AspNetCore.Cryptography.KeyDerivation;
using System.Security.Cryptography;

var password 	= "password";
var salt 		= new byte[16];
var iteration 	= 1000;

using (var generator = RandomNumberGenerator.Create())
{
    generator.GetBytes(salt);
}

Console.WriteLine(Hash(KeyDerivationPrf.HMACSHA1));
Console.WriteLine(Hash(KeyDerivationPrf.HMACSHA256));
Console.WriteLine(Hash(KeyDerivationPrf.HMACSHA512));

string Hash(KeyDerivationPrf prf)
{
    var hashed = KeyDerivation.Pbkdf2(
        password: password,
        salt: salt,
        prf: prf,
        iterationCount: iteration,
        numBytesRequested: 32);
    return Convert.ToBase64String(hashed);
}

上面的程式碼片段演示了如何為提供的密碼(「password」)生成指定位數(32位元組,256位)的哈希值。我們採用一個隨機生成的鹽值(16位元組,128位),執行1000次迭代,針對三種不同的哈希演算法生成對應的哈希值。Base64編碼後的三個哈希值以如圖13-5所示的方式輸出到控制台上。

image
圖5 採用PBKDF2生成的密碼哈希