C# 中的炫酷加密/解密






4.59/5 (62投票s)
方便使用的 .NET 加密/解密类
引言
使用 .NET 中内置的加密类可能会非常复杂。你需要充分了解自己在做什么,才能正确操作并以安全的方式工作。算法的选择、加密模式、密钥长度、块大小、理解什么是盐值(salt)以及如何使用它,还有如何将密码哈希为合适的密钥,这些都是你需要处理的事情。希望本文能让你的生活轻松许多。
对于那些正在寻找快速解决方案的人来说,这基本上就是加密和解密数据所需的代码。在可下载的代码中还有很多其他示例。
string enc = CryptoHelper.EncryptBase64String("password", AutoSaltSizes.Salt512, "data");
string dec = CryptoHelper.DecryptBase64String("password", AutoSaltSizes.Salt512, enc);
我的实现是一个类库,其中包含有用的函数,可以实现“一句话加密”,以帮助你应对最常见的情况。我还添加了一个我称之为 AutoSalt 的功能,它是一种进一步保护数据的方法,无需你付出额外的努力,只需决定盐值的大小即可。此外,我还对 CryptoStream
的工作方式进行了自己的实现,以对流中的操作获得额外的控制,这有助于处理更复杂的场景。
背景
我开始写这篇文章的原因是,我开始研究 CryptoStream
的加密功能,但它对我需要在项目中实现的目标毫无用处。原因是 CryptoStream
只能将所有内容通过流处理,而且无法在你正在加密时获取数据。我想这部分对我来说有点特殊,但我相信还有很多人有类似的问题。
一个简单的例子可以说明 CryptoStream
无法做到而我的实现可以轻松处理的事情:将一个 50GB 的文件一次性分割成一系列 100MB 的小文件。你可能见过 .rar 文件被这样分割,想法差不多。
我的项目这部分随后发展到编写一个完整的库,然后我感觉应该通过这篇文章分享一下。
为什么使用这个库
对我而言,安全性一直是这个库的一个真正关注点和日益增长的兴趣所在。因为加密不仅仅关乎算法,还关乎你如何以安全的方式存储密钥、生成密钥/盐值,确保你的应用程序在数据处理流程中的安全,以及确保你做出了正确的加密设置选择。即使是性能,如果你处理的是海量数据,也可能扮演重要角色。
显然,我并没有解决所有这些问题,但解决了一部分,它绝对会让你的生活更轻松,让你能够专注于代码质量。以下是我希望强调的几点:
- 我添加了一个名为 AutoSalt 的功能,它会创建加密安全的随机数据并将其与数据结合。所以,即使你两次加密相同的内容,加密后的数据看起来也会不同。
- 我确保你可以利用硬件加速(如果你的硬件支持)。
- 我以一种安全的方式生成密钥和初始化向量,这样你就无需为此烦恼。
- 你仍然可以灵活地使用 .NET 中所有可用的加密设置。
- 对于简单场景,提供的辅助类将为你提供“一句话”的加密使用方式,以一种简单的方式。
- 对于高级用户,你将能够在整个过程完成之前,切入并读取加密/解密的数据流。
所以,任何只是想加密一些简单内容,或者在使用 CryptoStream
时遇到问题(因为它不能达到你的要求)的人,都应该使用这个库。
Using the Code
这个库的构建宗旨是简单易用,同时兼顾灵活性和安全性。因此,你拥有对主类的访问权限,以便执行更高级的操作,并且有辅助类可以帮助你实现“一句话”操作。
在处理加密时,你可以选择以下对称算法、密钥大小和加密模式及块大小之一。我默认使用 AES,256 位密钥和 CBC 加密模式,因为它是当前的标准,而且如果你的 CPU 不是太老,通常可以使用 CPU 硬件加速,这总是很不错的。
以上网格中的组合表示在以下 Enum
中的选项。其格式为:[对称算法]_[密钥大小]_[加密模式]_[块大小]。我选择创建这样一个 Enum
是因为算法之间共享的组合很少,而且这样可以避免你给出无效的选项。块大小仅适用于 Rijndael,因为它是唯一支持块大小的对称算法。
public enum SymmetricCryptoAlgorithm
{
AES_128_CBC,
AES_128_ECB,
AES_128_CFB,
AES_192_CBC,
AES_192_ECB,
AES_256_CBC,
AES_256_ECB,
AES_256_CFB,
Rijndael_128_CBC_128,
Rijndael_128_CBC_192,
Rijndael_128_CBC_256,
Rijndael_128_ECB_128,
Rijndael_128_ECB_192,
Rijndael_128_ECB_256,
Rijndael_128_CFB_128,
Rijndael_128_CFB_192,
Rijndael_128_CFB_256,
Rijndael_192_CBC_128,
Rijndael_192_CBC_192,
Rijndael_192_CBC_256,
Rijndael_192_ECB_128,
Rijndael_192_ECB_192,
Rijndael_192_ECB_256,
Rijndael_192_CFB_128,
Rijndael_192_CFB_192,
Rijndael_192_CFB_256,
Rijndael_256_CBC_128,
Rijndael_256_CBC_192,
Rijndael_256_CBC_256,
Rijndael_256_ECB_128,
Rijndael_256_ECB_192,
Rijndael_256_ECB_256,
Rijndael_256_CFB_128,
Rijndael_256_CFB_192,
Rijndael_256_CFB_256,
DES_64_CBC,
DES_64_ECB,
DES_64_CFB,
TripleDES_128_CBC,
TripleDES_128_ECB,
TripleDES_128_CFB,
TripleDES_192_CBC,
TripleDES_192_ECB,
TripleDES_192_CFB,
RC2_40_CBC,
RC2_40_ECB,
RC2_40_CFB,
RC2_48_CBC,
RC2_48_ECB,
RC2_48_CFB,
RC2_56_CBC,
RC2_56_ECB,
RC2_56_CFB,
RC2_64_CBC,
RC2_64_ECB,
RC2_64_CFB,
RC2_72_CBC,
RC2_72_ECB,
RC2_72_CFB,
RC2_80_CBC,
RC2_80_ECB,
RC2_80_CFB,
RC2_88_CBC,
RC2_88_ECB,
RC2_88_CFB,
RC2_96_CBC,
RC2_96_ECB,
RC2_96_CFB,
RC2_104_CBC,
RC2_104_ECB,
RC2_104_CFB,
RC2_112_CBC,
RC2_112_ECB,
RC2_112_CFB,
RC2_120_CBC,
RC2_120_ECB,
RC2_120_CFB,
RC2_128_CBC,
RC2_128_ECB,
RC2_128_CFB
}
你还可以选择填充模式,我选择 PKCS7 作为默认填充,因为这似乎是...
public enum PaddingMode
{
None,
PKCS7,
Zeros,
ANSIX923,
ISO10126
}
在我提供的示例代码中,你可以看到不同设置之间的性能对比,我认为这将帮助你根据使用场景决定使用哪种设置。我还尝试说明不同的工作方式。
这是一个使用我的辅助类的示例,它可以帮助你实现用于加密或解密数据的“一句话”操作。
string key = "My super secret password";
string salt = "Salt value";
string dataString = "My secret data";
byte[] dataBytes = Encoding.Unicode.GetBytes(dataString);
// Encrypt/Decrypt strings
var encStr = CryptoHelper.EncryptString
(password, salt, dataString, SymmetricCryptoAlgorithm.TripleDES_192_CBC);
var decStr = CryptoHelper.DecryptString
(password, salt, encStr, SymmetricCryptoAlgorithm.TripleDES_192_CBC);
// Encrypt/Decrypt strings with AutoSalt
var encryptedStringAutoSalt =
CryptoHelper.EncryptString(password, AutoSaltSizes.Salt32, dataString);
var decryptedStringAutoSalt =
CryptoHelper.DecryptString(password, AutoSaltSizes.Salt32, encryptedStringAutoSalt);
// Encrypt/Decrypt byte arrays
var encryptedBytes = CryptoHelper.EncryptData(password, salt, dataBytes);
var decryptedBytes = CryptoHelper.DecryptData(password, salt, encryptedBytes);
// Encrypt/Decrypt byte arrays with AutoSalt
var encryptedBytesAutoSalt = CryptoHelper.EncryptData
(password, AutoSaltSizes.Salt64, dataBytes);
var decryptedBytesAutoSalt = CryptoHelper.DecryptData
(password, AutoSaltSizes.Salt64, encryptedBytesAutoSalt);
// Encrypt/Decrypt files
CryptoHelper.EncryptFile(password, salt, "testdata.txt", "testdata encrypted.txt");
CryptoHelper.DecryptFile(password, salt, "testdata encrypted.txt", "testdata decrypted.txt");
// Encrypt/Decrypt files with AutoSalt
CryptoHelper.EncryptFile(password, AutoSaltSizes.Salt128,
"testdata.txt", "testdata encrypted.txt");
CryptoHelper.DecryptFile(password, AutoSaltSizes.Salt128,
"testdata encrypted.txt", "testdata decrypted.txt");
以下示例展示了如何在最简单的场景中使用主类。
重要细节: 由于加密类的内部工作原理,它需要知道何时完成数据输入缓冲区,以便添加填充。为了在我的代码中处理这个问题,你需要在 AddData
函数中将 lastData
标志设置为 true
,表示所有数据都已添加。
string dataStr = "My super secret data that I want to encrypt";
byte[] bytes = Encoding.Unicode.GetBytes(dataStr);
EncryptionBuffer encBuffer = new EncryptionBuffer("My super secret password",
"Salt value", SymmetricCryptoAlgorithm.AES_256_CBC);
encBuffer.AddData(bytes, true);
byte[] encryptedBytes = encBuffer.GetData();
string encryptedString = Encoding.Unicode.GetString(encryptedBytes);
DecryptionBuffer decBuffer = new DecryptionBuffer("My super secret password",
"Salt value", SymmetricCryptoAlgorithm.AES_256_CBC);
decBuffer.AddData(encryptedBytes, true);
byte[] decryptedBytes = decBuffer.GetData();
string decryptedString = Encoding.Unicode.GetString(decryptedBytes);
这里有一个更高级的示例,我将文件分块读取、加密分块,然后保存到另一个文件,再读回来。这个示例可以轻松修改以满足你的任何需求。它应该是线程安全的,这样你就可以从两个不同的线程同时读写数据(如果需要的话)。
// Encrypting a file and writing the encrypted file at the same time
using (FileStream originalFile = new FileStream("testdata.txt", FileMode.Open, FileAccess.Read))
{
using (FileStream encryptedFile =
new FileStream("testdata.enc", FileMode.OpenOrCreate, FileAccess.Write))
{
EncryptionBuffer encBuffer =
new EncryptionBuffer("My super secret password", "Salt value");
byte[] fileData = new byte[10000];
bool isLastData = false;
while (!isLastData)
{
int nrOfBytes = originalFile.Read(fileData, 0, fileData.Length);
isLastData = (nrOfBytes == 0);
encBuffer.AddData(fileData, 0, nrOfBytes, isLastData);
byte[] encryptedData = encBuffer.GetData();
encryptedFile.Write(encryptedData, 0, encryptedData.Length);
}
}
}
// Decrypting the encrypted file
using (FileStream encryptedFile =
new FileStream("testdata.enc", FileMode.Open, FileAccess.Read))
{
using (FileStream decryptedFile =
new FileStream("testdata.dec", FileMode.OpenOrCreate, FileAccess.Write))
{
DecryptionBuffer decBuffer =
new DecryptionBuffer("My super secret password", "Salt value");
byte[] fileData = new byte[10000];
bool isLastData = false;
while (!isLastData)
{
int nrOfBytes = encryptedFile.Read(fileData, 0, fileData.Length);
isLastData = (nrOfBytes == 0);
decBuffer.AddData(fileData, 0, nrOfBytes, isLastData);
byte[] decryptedData = decBuffer.GetData();
decryptedFile.Write(decryptedData, 0, decryptedData.Length);
}
}
}
AutoSalt 及其使用原因
使用盐值(Salt)通常与存储密码哈希值相关,而不是用于加密数据。但显然,它也可以用于存储加密数据。盐值实际上应该是一个由 100% 随机数据组成的公共密钥。它也应该与加密数据一起存储。
使用盐值的原因是为了进一步混淆数据,主要是为了隐藏模式。如果你多次加密相同的数据,加密后的结果将完全相同。为这些数据引入适当的盐值将使结果每次都不同。
我在这里使用 AutoSalt 的做法是生成一个长度可设的随机盐值,然后将盐密钥连接到加密数据中。所以,如果你选择使用 AutoSaltSizes.Salt256
来加密你的数据,那么 32 字节的随机数据将被添加到你的加密数据中。
public enum AutoSaltSizes:int
{
Salt32 = 4,
Salt64 = 8,
Salt128 = 16,
Salt192 = 24,
Salt256 = 32,
Salt384 = 48,
Salt512 = 64
}
以下是一个使用 AutoSalt 的代码示例。它非常易于使用,并且提供了额外的安全价值,你可能永远不应考虑不使用它。自己处理盐值只会增加你的工作量。
string dataString = "My secret data";
string enc = CryptoHelper.EncryptString("password", AutoSaltSizes.Salt64, dataString);
string dec = CryptoHelper.DecryptString("password", AutoSaltSizes.Salt64, enc);
以下是我示例代码中的一个片段,演示了使用不同输出加密相同数据的效果。
性能
这是在一台 Intel i7-3770 CPU 上,针对不同加密算法对完全相同的数据进行加密/解密性能的对比。X 轴标签的读取方式是:算法_密钥大小_加密模式_块大小,块大小部分仅用于 Rijndael,因为它是唯一可以选择修改块大小的算法。
查看对所有算法的比较,可以清楚地看到 AES 比其他任何算法都快得多。原因是:我使用的是 AesCryptoServiceProvider
而不是 AesManaged
。AesCryptoServiceProvider
使用 Windows 操作系统 API,它支持 AES-NI(只要可用),即较新 Intel CPU(如 Core 和 Xeon 系列)支持的硬件加速加密。AesManaged
是 AES 算法的完全托管实现,永远不会利用硬件加速。
AES
仔细查看 AES,有趣的是 CFB 加密模式在硬件加速 AES 上的表现并不好。我只能假设这种模式不支持加速。
在这个库中,我忽略了托管版本,因为它们提供相同的结果但速度较慢。对于其他算法,也有 CryptoServiceProviders
,只有 Rijndael 是托管的。我猜想,如果你有较旧的硬件,可能会获得 RC2 的加速,以及 DES 和 T-DES。我尚未证实这一点。
你还可以看到,除了较小块大小的 Rijndael 之外,CFB 加密模式似乎大大降低了算法的速度。我认为应避免使用 CFB,除非你认为缓慢的算法能增加安全性。
由于其良好的性能,并且 AES 是自 2001 年 11 月 26 日起的新 FIPS 标准,因此你应该始终选择 AES_256_CBC
。这将确保良好的安全性和性能。有些人认为 Rijndael 是最好的,但实际上 AES 是 Rijndael 的一个子集,它只支持更大的块大小,这是否能提高安全性我不敢说,但由于缺乏硬件加速支持,我无法证明使用它的合理性。使用 AES 以外的任何东西的唯一原因是兼容性。
以下是所有对称算法的性能测试结果。
Rijndael
DES
三 DES
RC2
关注点
什么是盐值?为什么/通常如何使用它?
我在这个加密库中为加密数据实现了盐值,我称之为 AutoSalt,但盐值更常用于存储用户账户的密码。
如果你曾经构建过一个处理用户账户和存储凭据的应用程序,那么如果你正确实现了它,很可能你会遇到盐值。希望你那时会意识到,以明文或可逆加密的方式存储用户账户的密码不是一个好主意。正确的方法是创建一个无法反转的密码哈希值。然而,除非你使用正确的哈希算法,否则这也不是真正安全的。而且,为了进一步增强哈希值,你也应该使用盐值。
盐值不过是一组随机数据,它被哈希到密码中。盐值应该对你哈希的每个密码集都是唯一的,并且不一定需要保密,例如它可以与哈希值一起存储在数据库表中。
使用这种盐值的优点是,你存储的加盐哈希值对于你哈希的每个值都是唯一的。所以即使我为两个不同的用户存储相同的密码,哈希值也会因盐值而不同。这将阻止攻击者在你数据库被盗后使用预编译查找、反向查找或他可能已经准备好的彩虹表,因为他事先不知道哈希值。攻击者需要对每个记录进行攻击,这将更加困难,也更耗时。
错误地处理密码哈希的一个常见例子是,在没有盐值的情况下使用 MD5 哈希密码。有一些网站,如 http://www.md5online.org/,可以作为反向查找,你输入哈希值,它会告诉你原始数据是什么。即使 MD5 不被认为是足够安全的,有了盐值,这样也很难反转密码。
关于密码哈希还有很多可以说的,我觉得这有点偏离我的文章主题。如果你想了解更多关于正确加盐方法的知识,可以 在这里 阅读。
我使用 Rfc2898DeriveBytes
实现盐值。这是推荐使用的类,它使我能够轻松地组合密码和盐值,并为 key
和 init
向量提供字节。盐值本身使用 RNGCryptoServiceProvider
生成,这是推荐用于生成加密安全数据的类。
使用以下代码,你可以生成一个对于用户账户目的来说完美安全的盐值和哈希值。
byte[] salt = new byte[128];
using (RNGCryptoServiceProvider rng = new RNGCryptoServiceProvider())
rng.GetBytes(salt);
Rfc2898DeriveBytes key = new Rfc2898DeriveBytes("user password", salt, 100);
string passwordhash = Convert.ToBase64String(key.GetBytes(100));
什么是对称算法?我们使用哪种算法?
对称算法(也称为密钥算法)是一种使用相同密钥来加密或解密数据的算法。 .NET 框架支持的算法如下:
- DES
- RC2
- TripleDES
- AES
- Rijndael
在我的实现中,你可以选择其中任何一种。今天的标准是 AES,这也是我默认在我的库中使用的原因。
AES 是 Rijndael 的一个子集,据说 Rijndael 更安全。我猜想原因是 Rijndael 支持更大的块大小和 CFB 加密模式。我不确定这在多大程度上提高了安全性。Rijndael 在 .NET 中不支持硬件加速,而 AES 支持,所以我认为你应该始终选择 AES。
加密模式
.NET 支持多种不同的加密模式。任何一种都可以,只要避免 ECB 模式。我认为以下维基百科页面对此进行了很好的解释:
填充和填充模式
一个常见的问题/困惑是为什么加密后的数据总是比原始数据长。原因是你可以为 SymmetricAlgorithm
设置的 BlockSize
参数。根据你选择的算法,你会有不同的块大小。
例如,如果我们有一个 128 位(16 字节)的块大小,这意味着加密数据的长度总是 16 字节的倍数,并且总会有填充。所以,如果加密内容的大小已经是 16 的倍数,你也会得到一个额外的填充块。填充的长度绝不会超过块大小。
128 位块大小示例
- 如果你加密 27 字节,最终会得到 32 字节的加密数据。
- 如果你加密 32 字节,最终会得到 48 字节的加密数据。
64 位块大小示例
- 如果你加密 27 字节,最终会得到 32 字节的加密数据。
- 如果你加密 32 字节,最终会得到 40 字节的加密数据。
解密数据时,这些填充字节当然会消失。
在我的实现中,你可以选择想要的填充,但除非你有理由更改填充,否则我建议坚持使用默认填充,即 PKCS7
。
到目前为止,我还没有发现任何理由自己更改它,除非当你将填充模式设置为 None
时。在这种情况下,你需要在提供给密码的数据中自己处理填充。它只接受块大小的倍数的数据。这仅对真正的高级用户有用。
如果你有兴趣深入了解填充的更多细节,可以阅读 http://en.wikipedia.org/wiki/Padding_(cryptography),关于块大小可以在 http://en.wikipedia.org/wiki/Block_size_(cryptography) 阅读。