在本章中,我们将讨论 .NET 中的主要加密 API:
- Windows Data Protection (DPAPI)
- 散列法
- 对称加密
- 公钥加密和签名
本章中介绍的类型在以下命名空间中定义:
System.Security;
System.Security.Cryptography;
概述
汇总了 .NET 中的加密选项。在其余部分中,我们将逐一探讨其中的每一个。
.NET 中的加密和哈希选项 | ||||
选择 | 要管理的密钥 | 速度 | 强度 | 笔记 |
文件加密 | 0 | 快 | 取决于用户的密码 | 通过文件系统支持透明地保护文件。密钥隐式派生自登录用户的凭据。仅限窗口。 |
视窗数据保护 | 0 | 快 | 取决于用户的密码 | 使用隐式派生密钥加密和解密字节数组 |
散列法 | 0 | 快 | 高 | 单向(不可逆)转换。用于存储密码、比较文件和检查数据损坏 |
对称加密 | 1 | 快 | 高 | 用于通用加密/解密。相同的密钥加密和解密。可用于保护传输中的邮件 |
公钥加密 | 2 | 慢 | 高 | 加密和解密使用不同的密钥。用于在消息传输中交换对称密钥和对文件进行数字签名 |
.NET 还为在 System.Security.Cryptography.Xml 中创建和验证基于 XML 的签名以及用于处理 System.Security.Cryptography.X509Certificates 中的数字证书的类型提供了更专业的支持。
视窗数据保护
注意
Windows 数据保护仅在 Windows 上可用,并在其他操作系统上抛出 PlatformNotSupportedException。
在第 的一节中,我们描述了如何使用 File.Encrypt 请求操作系统透明地加密文件:
File.WriteAllText ("myfile.txt", "");
File.Encrypt ("myfile.txt");
File.AppendAllText ("myfile.txt", "sensitive data");
在这种情况下,加密使用从登录用户的密码派生的密钥。您可以使用相同的隐式派生密钥通过 Windows 数据保护 API (DPAPI) 加密字节数组。DPAPI 通过 ProtectedData 类公开,类是一种具有两种静态方法的简单类型:
public static byte[] Protect
(byte[] userData, byte[] optionalEntropy, DataProtectionScope scope);
public static byte[] Unprotect
(byte[] encryptedData, byte[] optionalEntropy, DataProtectionScope scope);
无论您包含在 optionalEntropy 中的任何内容都会添加到密钥中,从而提高其安全性。DataProtectionScope 枚举参数允许两个选项:CurrentUser 或 LocalMachine 。使用当前用户,密钥派生自登录用户的凭据;使用本地计算机 ,使用计算机范围的密钥,这是所有用户共有的。这意味着,使用 CurrentUser 范围时,一个用户加密的数据不能被另一个用户解密。本地计算机密钥提供的保护较少,但在 Windows 服务或需要在各种帐户下运行的程序下工作。
下面是一个简单的加密和解密演示:
byte[] original = {1, 2, 3, 4, 5};
DataProtectionScope scope = DataProtectionScope.CurrentUser;
byte[] encrypted = ProtectedData.Protect (original, null, scope);
byte[] decrypted = ProtectedData.Unprotect (encrypted, null, scope);
// decrypted is now {1, 2, 3, 4, 5}
Windows 数据保护针对对计算机具有完全访问权限的攻击者提供中等安全性,具体取决于用户密码的强度。对于本地计算机范围,它仅对物理和电子访问受限的用户有效。
散列法
哈希将潜在的大量字节提取为一个小的固定长度。哈希算法的设计使得源数据中任意位置的单位更改会导致明显不同的哈希码。这使其适用于比较文件或检测文件或数据流的意外(或恶意)损坏。
哈希还充当单向加密,因为很难将哈希码转换回原始数据。这使其成为在数据库中存储密码的理想选择,因为如果您的数据库遭到入侵,您不希望攻击者获得对纯文本密码的访问权限。要进行身份验证,只需对用户键入的内容进行哈希处理,并将其与数据库中存储的哈希进行比较。
要进行哈希处理,您可以在其中一个 HashAlgorithm 子类(如 SHA1 或 SHA256)上调用 ComputeHash:
byte[] hash;
using (Stream fs = File.OpenRead ("checkme.doc"))
hash = SHA1.Create().ComputeHash (fs); // SHA1 hash is 20 bytes long
ComputeHash方法还接受字节数组,这对于散列密码很方便(我们在中描述了一种更安全的技术):
byte[] data = System.Text.Encoding.UTF8.GetBytes ("stRhong%pword");
byte[] hash = SHA256.Create().ComputeHash (data);
注意
编码对象上的 GetBytes 方法将字符串转换为字节数组;方法将其转换回来。但是,编码对象无法将加密或哈希字节数组转换为字符串,因为加扰的数据通常会违反文本编码规则。相反,请使用 Convert.ToBase64String 和 Convert.FromBase64String :它们在任何字节数组和合法(XML或JSON友好)字符串之间进行转换。
.NET 中的哈希算法
SHA1 和 SHA256 是 .NET 提供的两种哈希算法子类型。以下是所有主要算法,按安全性升序排列(和哈希长度,以字节为单位):
MD5(16) → SHA1(20) → SHA256(32) → SHA384(48) → SHA512(64)
MD5 和 SHA1 是目前最快的算法,尽管其他算法在当前的实现中不超过(大约)慢两倍。为了给出一个大概的数字,在当今的典型桌面或服务器上,使用这些算法中的任何一种都可以预期性能超过每秒 100 MB。较长的哈希值可降低的可能性(两个不同的文件产生相同的哈希值)。
注意
在散列密码或其他安全敏感数据时使用 SHA256。MD5 和 SHA1 被视为不安全的,并且仅适用于防止意外损坏,而不适用于故意篡改。
哈希密码
如果您强制实施强密码策略来缓解字典攻击(攻击者通过对中的每个单词进行哈希处理来构建密码查找表的策略),则较长的 SHA 算法适合作为密码哈希的基础。
散列密码时,一种标准技术是合并“盐”,即最初通过随机数生成器获得的一长串字节,然后在散列之前与每个密码组合。这在两个方面使黑客感到沮丧:
- 他们还必须知道盐字节。
- 他们不能使用(公开可用的预先计算的密码及其哈希码数据库),尽管在有足够的能力的情况下,字典攻击仍然是可能的。
您可以通过“延伸”密码哈希来进一步增强安全性 - 重复重新哈希以获得计算量更大的字节序列。如果你重复100次,否则可能需要一个月的字典攻击将需要八年时间。KeyDerivation、Rfc2898DeriveBytes 和 PasswordDeriveBytes 类正是执行这种拉伸,同时还允许方便的加盐。其中,KeyDerivation.Pbkdf2提供了最好的哈希:
byte[] encrypted = KeyDerivation.Pbkdf2 (
password: "stRhong%pword",
salt: Encoding.UTF8.GetBytes ("j78Y#p)/saREN!y3@"),
prf: KeyDerivationPrf.HMACSHA512,
iterationCount: 100,
numBytesRequested: 64);
注意
KeyDerivation.Pbkdf2 需要 NuGet 包 Microsoft.AspNetCore.Cryptography.KeyDerivation 。尽管它位于 ASP.NET Core 命名空间中,但任何 .NET 应用程序都可以使用它。
对称加密
对称加密使用与解密相同的密钥进行加密。.NET BCL 提供了四种对称算法,其中 Rijndael(发音为“Rhine Dahl”或“雨娃娃”)是高级算法;其他算法主要用于与旧应用程序的兼容性。Rijndael既快速又安全,并且有两种实现:
- 莱恩戴尔级
- 艾斯级
两者几乎相同,只是 Aes 不允许通过更改块大小来削弱密码。Aes 由 CLR 的安全团队推荐。
Rijndael 和 Aes 允许长度为 16、24 或 32 字节的对称密钥:目前都被认为是安全的。下面介绍如何使用 16 字节密钥在写入文件时加密一系列字节:
byte[] key = {145,12,32,245,98,132,98,214,6,77,131,44,221,3,9,50};
byte[] iv = {15,122,132,5,93,198,44,31,9,39,241,49,250,188,80,7};
byte[] data = { 1, 2, 3, 4, 5 }; // This is what we're encrypting.
using (SymmetricAlgorithm algorithm = Aes.Create())
using (ICryptoTransform encryptor = algorithm.CreateEncryptor (key, iv))
using (Stream f = File.Create ("encrypted.bin"))
using (Stream c = new CryptoStream (f, encryptor, CryptoStreamMode.Write))
c.Write (data, 0, data.Length);
以下代码解密该文件:
byte[] key = {145,12,32,245,98,132,98,214,6,77,131,44,221,3,9,50};
byte[] iv = {15,122,132,5,93,198,44,31,9,39,241,49,250,188,80,7};
byte[] decrypted = new byte[5];
using (SymmetricAlgorithm algorithm = Aes.Create())
using (ICryptoTransform decryptor = algorithm.CreateDecryptor (key, iv))
using (Stream f = File.OpenRead ("encrypted.bin"))
using (Stream c = new CryptoStream (f, decryptor, CryptoStreamMode.Read))
for (int b; (b = c.ReadByte()) > -1;)
Console.Write (b + " "); // 1 2 3 4 5
在这个例子中,我们组成了一个由 16 个随机选择的字节组成的键。如果在解密中使用了错误的密钥,CryptoStream将抛出CryptographicException。捕获此异常是测试密钥是否正确的唯一方法。
除了密钥之外,我们还组成了一个IV或。这个 16 字节序列构成了密码的一部分(非常类似于密钥),但不被视为。如果要传输加密邮件,则应以纯文本形式(可能在邮件头中)发送 IV,然后。这将使每封加密邮件无法与以前的任何邮件识别,即使它们的未加密版本相似或相同。
注意
如果不需要或不需要 IV 的保护,则可以通过对密钥和 IV 使用相同的 16 字节值来击败它。但是,使用相同的IV发送多条消息会削弱密码,甚至可能使其有可能被破解。
密码学工作在各个类之间划分。艾斯是数学家;它应用密码算法及其加密器和解密器转换。加密流是水管工;它负责溪流管道。您可以使用不同的对称算法替换 Aes,但仍使用 CryptoStream 。
CryptoStream 是,这意味着您可以读取或写入流,具体取决于您是选择 CryptoStreamMode.Read 还是 CryptoStreamMode.Write 。加密器和解密器都,产生四种组合 - 选择可能会让您盯着空白屏幕一段时间!将阅读建模为“拉”和写作建模为“推动”会很有帮助。如有疑问,请从写入进行加密和读取以进行解密开始;这通常是最自然的。
要生成随机密钥或 IV,请使用 System.Cryptography 中的 RandomNumberGenerator。它产生的数字确实是不可预测的,或者加密(System.Random 类不提供相同的保证)。下面是一个:
byte[] key = new byte [16];
byte[] iv = new byte [16];
RandomNumberGenerator rand = RandomNumberGenerator.Create();
rand.GetBytes (key);
rand.GetBytes (iv);
如果未指定密钥和 IV,则会自动生成加密强度高的随机值。可以通过 Aes 对象的键和 IV 属性查询这些。
在内存中加密
从 .NET 6 开始,可以利用 EncryptCbc 和 DecryptCbc 方法来缩短加密和解密字节数组的过程:
public static byte[] Encrypt (byte[] data, byte[] key, byte[] iv)
{
using Aes algorithm = Aes.Create();
algorithm.Key = key;
return algorithm.EncryptCbc (data, iv);
}
public static byte[] Decrypt (byte[] data, byte[] key, byte[] iv)
{
using Aes algorithm = Aes.Create();
algorithm.Key = key;
return algorithm.DecryptCbc (data, iv);
}
以下是适用于 all.NET 版本的等效项:
public static byte[] Encrypt (byte[] data, byte[] key, byte[] iv)
{
using (Aes algorithm = Aes.Create())
using (ICryptoTransform encryptor = algorithm.CreateEncryptor (key, iv))
return Crypt (data, encryptor);
}
public static byte[] Decrypt (byte[] data, byte[] key, byte[] iv)
{
using (Aes algorithm = Aes.Create())
using (ICryptoTransform decryptor = algorithm.CreateDecryptor (key, iv))
return Crypt (data, decryptor);
}
static byte[] Crypt (byte[] data, ICryptoTransform cryptor)
{
MemoryStream m = new MemoryStream();
using (Stream c = new CryptoStream (m, cryptor, CryptoStreamMode.Write))
c.Write (data, 0, data.Length);
return m.ToArray();
}
在这里,CryptoStreamMode.Write最适合加密和解密,因为在这两种情况下,我们都“推送”到一个新的内存流中。
以下是接受和返回字符串的重载:
public static string Encrypt (string data, byte[] key, byte[] iv)
{
return Convert.ToBase64String (
Encrypt (Encoding.UTF8.GetBytes (data), key, iv));
}
public static string Decrypt (string data, byte[] key, byte[] iv)
{
return Encoding.UTF8.GetString (
Decrypt (Convert.FromBase64String (data), key, iv));
}
下面演示了它们的用法:
byte[] key = new byte[16];
byte[] iv = new byte[16];
var cryptoRng = RandomNumberGenerator.Create();
cryptoRng.GetBytes (key);
cryptoRng.GetBytes (iv);
string encrypted = Encrypt ("Yeah!", key, iv);
Console.WriteLine (encrypted); // R1/5gYvcxyR2vzPjnT7yaQ==
string decrypted = Decrypt (encrypted, key, iv);
Console.WriteLine (decrypted); // Yeah!
链接加密流
CryptoStream是一个装饰器,这意味着您可以将其与其他流链接在一起。在以下示例中,我们将压缩的加密文本写入文件,然后将其读回:
byte[] key = new byte [16];
byte[] iv = new byte [16];
var cryptoRng = RandomNumberGenerator.Create();
cryptoRng.GetBytes (key);
cryptoRng.GetBytes (iv);
using (Aes algorithm = Aes.Create())
{
using (ICryptoTransform encryptor = algorithm.CreateEncryptor(key, iv))
using (Stream f = File.Create ("serious.bin"))
using (Stream c = new CryptoStream (f, encryptor, CryptoStreamMode.Write))
using (Stream d = new DeflateStream (c, CompressionMode.Compress))
using (StreamWriter w = new StreamWriter (d))
await w.WriteLineAsync ("Small and secure!");
using (ICryptoTransform decryptor = algorithm.CreateDecryptor(key, iv))
using (Stream f = File.OpenRead ("serious.bin"))
using (Stream c = new CryptoStream (f, decryptor, CryptoStreamMode.Read))
using (Stream d = new DeflateStream (c, CompressionMode.Decompress))
using (StreamReader r = new StreamReader (d))
Console.WriteLine (await r.ReadLineAsync()); // Small and secure!
}
(最后,我们通过调用 WriteLineAsync 和 ReadLineAsync 来使我们的程序异步,并等待结果。
在此示例中,所有单字母变量构成链的一部分。数学家——算法、加密器和解密器——在那里协助CryptoStream进行密码工作,如图所示。
链接加密和压缩流
无论最终流大小如何,以这种方式链接流都需要很少的内存。
释放加密对象
释放加密流可确保其内部数据缓存刷新到底层流。内部缓存对于加密算法是必需的,因为它们以块的形式处理数据,而不是一次处理一个字节。
CryptoStream的不寻常之处在于它的Flush方法什么都不做。要刷新流(不释放它),您必须调用 FlushFinalBlock 。与 Flush 相反,您只能调用 FlushFinalBlock 一次,然后就不能再写入数据了。
我们还处理了数学家——Aes算法和ICryptoTransform对象(加密器和解密器)。当 Rijndael 转换被释放时,它们会从内存中擦除对称密钥和相关数据,从而防止随后被计算机上运行的其他软件发现(我们说的是恶意软件)。您不能依赖垃圾回收器来完成此作业,因为它只是将内存部分标记为可用;它不会在每个字节上写入零。
在 using 语句之外释放 Aes 对象的最简单方法是调用 Clear 。它的 Dispose 方法通过显式实现隐藏(以指示其不寻常的处置语义,从而清除内存而不是释放非托管资源)。
注意
通过执行以下操作,可以进一步降低应用程序通过释放的内存泄露机密的漏洞:
- 避免使用安全信息的字符串(不可变,字符串的值一旦创建就永远无法清除)
- 在不再需要缓冲区时立即覆盖缓冲区(例如,通过在字节数组上调用 Array.Clear )
密钥管理
密钥管理是安全的关键要素:如果您的密钥暴露,您的数据也会暴露。您需要考虑谁应该有权访问密钥,以及如何在发生硬件故障时备份它们,同时以防止未经授权的访问的方式存储它们。
不建议对加密密钥进行硬编码,因为存在用于反编译程序集的常用工具,几乎不需要专业知识。更好的选择(在Windows上)是为每个安装制造一个随机密钥,并使用Windows数据保护安全地存储它。
对于部署到云的应用程序,Microsoft Azure 和 Amazon Web Services (AWS) 提供密钥管理系统,这些系统具有在企业环境中非常有用的其他功能,例如审计跟踪。
如果您正在加密消息流,公钥加密仍然是最佳选择。
公钥加密和签名
公钥加密是,这意味着加密和解密使用不同的密钥。
与对称加密不同,对称加密(任何适当长度的任意字节序列都可以用作密钥),非对称加密需要特制的密钥对。密钥对组件,它们按如下方式协同工作:
- 公钥对消息进行加密。
- 私钥解密消息。
“制作”密钥对的一方在自由分发公钥的同时保持私钥的秘密。这种类型的加密的一个特点是您无法从公钥计算私钥。因此,如果私钥丢失,则无法恢复加密数据;相反,如果私钥泄露,加密系统将变得无用。
公钥握手允许两台计算机通过公共网络安全地通信,无需事先联系,也没有现有的共享机密。若要了解其工作原理,假设计算机 想要向计算机 发送机密消息:
- 生成公钥/私钥对,然后将其公钥发送到。
- 使用 Target 的公钥对机密消息进行加密,然后将其发送到 。
- 使用其私钥解密机密消息。
窃听者将看到以下内容:
- 的公钥
- 使用 的公钥加密的机密消息
但是如果没有 的私钥,消息就无法解密。
注意
这并不能阻止中间人攻击:换句话说,无法知道不是恶意方。要对收件人进行身份验证,发起方需要已经知道收件人的公钥,或者能够通过验证其密钥。
由于公钥加密相对较慢且消息大小有限,因此从发送到的机密消息通常包含用于后续加密的新密钥。这允许在会话的剩余时间内放弃公钥加密,转而使用能够处理较大消息的对称算法。如果为每个会话生成新的公钥/私钥对,则此协议特别安全,因为不需要在任一存储密钥。
注意
公钥加密算法依赖于消息小于密钥。这使得它们仅适用于加密少量数据,例如用于后续对称加密的密钥。如果尝试加密远大于密钥大小一半的消息,提供程序将引发异常。
RSA 类
.NET 提供了许多非对称算法,其中 RSA 是最受欢迎的。以下是使用 RSA 加密和解密的方法:
byte[] data = { 1, 2, 3, 4, 5 }; // This is what we're encrypting.
using (var rsa = new RSACryptoServiceProvider())
{
byte[] encrypted = rsa.Encrypt (data, true);
byte[] decrypted = rsa.Decrypt (encrypted, true);
}
Because we didn’t specify a public or private key, the cryptographic provider automatically generated a key pair, using the default length of 1,024 bits; you can request longer keys in increments of eight bytes, through the constructor. For security-critical applications, it’s prudent to request 2,048 bits:
var rsa = new RSACryptoServiceProvider (2048);
生成密钥对是计算密集型的,可能需要 100 毫秒。因此,RSA 实现会延迟此操作,直到实际需要密钥,例如调用 Encrypt 时。这使您有机会加载现有密钥或密钥对(如果存在)。
方法 ImportCspBlob 和 ExportCspBlob 以字节数组格式加载和保存密钥。FromXmlString 和 ToXmlString 以字符串格式(包含 XML 片段的字符串)执行相同的工作。布尔标志允许您指示在保存时是否包含私钥。以下是制造密钥对并将其保存到磁盘的方法:
using (var rsa = new RSACryptoServiceProvider())
{
File.WriteAllText ("PublicKeyOnly.xml", rsa.ToXmlString (false));
File.WriteAllText ("PublicPrivate.xml", rsa.ToXmlString (true));
}
由于我们没有提供现有密钥,ToXmlString 强制制造新的密钥对(在第一次调用时)。在下一个示例中,我们读回这些密钥并使用它们来加密和解密消息:
byte[] data = Encoding.UTF8.GetBytes ("Message to encrypt");
string publicKeyOnly = File.ReadAllText ("PublicKeyOnly.xml");
string publicPrivate = File.ReadAllText ("PublicPrivate.xml");
byte[] encrypted, decrypted;
using (var rsaPublicOnly = new RSACryptoServiceProvider())
{
rsaPublicOnly.FromXmlString (publicKeyOnly);
encrypted = rsaPublicOnly.Encrypt (data, true);
// The next line would throw an exception because you need the private
// key in order to decrypt:
// decrypted = rsaPublicOnly.Decrypt (encrypted, true);
}
using (var rsaPublicPrivate = new RSACryptoServiceProvider())
{
// With the private key we can successfully decrypt:
rsaPublicPrivate.FromXmlString (publicPrivate);
decrypted = rsaPublicPrivate.Decrypt (encrypted, true);
}
数字签名
您还可以使用公钥算法对邮件或文档进行数字签名。签名就像一个哈希,只是它的制作需要私钥,因此不能伪造。公钥用于验证签名。下面是一个示例:
byte[] data = Encoding.UTF8.GetBytes ("Message to sign");
byte[] publicKey;
byte[] signature;
object hasher = SHA1.Create(); // Our chosen hashing algorithm.
// Generate a new key pair, then sign the data with it:
using (var publicPrivate = new RSACryptoServiceProvider())
{
signature = publicPrivate.SignData (data, hasher);
publicKey = publicPrivate.ExportCspBlob (false); // get public key
}
// Create a fresh RSA using just the public key, then test the signature.
using (var publicOnly = new RSACryptoServiceProvider())
{
publicOnly.ImportCspBlob (publicKey);
Console.Write (publicOnly.VerifyData (data, hasher, signature)); // True
// Let's now tamper with the data, and recheck the signature:
data[0] = 0;
Console.Write (publicOnly.VerifyData (data, hasher, signature)); // False
// The following throws an exception as we're lacking a private key:
signature = publicOnly.SignData (data, hasher);
}
签名的工作原理是首先对数据进行哈希处理,然后将非对称算法应用于生成的哈希。由于哈希的固定大小较小,因此可以相对快速地对大型文档进行签名(公钥加密比哈希占用 CPU 资源大得多)。如果需要,您可以自己进行哈希处理,然后调用 SignHash 而不是 SignData:
using (var rsa = new RSACryptoServiceProvider())
{
byte[] hash = SHA1.Create().ComputeHash (data);
signature = rsa.SignHash (hash, CryptoConfig.MapNameToOID ("SHA1"));
...
}
SignHash 仍然需要知道你使用了什么哈希算法;CryptoConfig.MapNameToOID 以正确的格式从友好名称(如“SHA1”)提供此信息。
RSACryptoServiceProvider 生成大小与密钥大小匹配的签名。目前,没有主流算法生成明显小于128字节的安全签名(例如,适用于产品激活码)。
注意
要使签名生效,收件人必须知道并信任发件人的公钥。这可以通过事先的通信、预配置或站点证书来实现。站点证书是发起方公钥和名称的电子记录,其本身由独立的受信任机构签名。命名空间 System.Security.Cryptography.X509Certificates 定义了用于处理证书的类型。