65.9K
CodeProject 正在变化。 阅读更多。
Home

C# 中的炫酷加密/解密

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.59/5 (62投票s)

2014 年 7 月 10 日

MIT

13分钟阅读

viewsIcon

208259

downloadIcon

3996

方便使用的 .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 而不是 AesManagedAesCryptoServiceProvider 使用 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 实现盐值。这是推荐使用的类,它使我能够轻松地组合密码和盐值,并为 keyinit 向量提供字节。盐值本身使用 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) 阅读。

© . All rights reserved.