ASP.NET Core 6框架揭祕例項演示[19]:資料加解密與雜湊

語言: CN / TW / HK

資料保護(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異常。

圖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異常。

圖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異常。

圖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異常。

圖4利用EphemeralDataProtectionProvider提供“瞬時”加解密

[S1307]金鑰雜湊

使用者密碼作為機密性最高的資訊是不能以明文形式儲存的,我們一般會儲存密碼的雜湊值。雖然雜湊的非對稱性確保不能直接通過雜湊值得到被雜湊的原始內容,但是在強大的算力面前已經不足以提供我們期望的安全保障。針對金鑰的保護,目前最安全的雜湊方式應該是PBKDF2(Password-Based Key Derivation Function 2)。PBKDF2是一種基於密碼的Key Derivation(採用某種演算法根據指定的密碼或者主鍵生成一個金鑰)函式,它採用偽隨機函式以任意指定長度匯出金鑰。它目前是RSA實驗室公鑰加密標準(PKCS:Public-Key Cryptography Standards)序列的一部分。PBKDF2提高安全係數主要採用“新增隨機鹽(Salt)”和“多次雜湊”這兩種手段。如果希望對PBKDF2具有深入的瞭解,可以參閱官方規範文件(https://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所示的方式輸出到控制檯上。

圖5採用PBKDF2生成的密碼雜湊