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具有深入的了解,可以参阅官方规范文档(http://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生成的密码哈希