密码的故事
Billy Hollis
2002 年 3 月 14 日
本文是由一个问题引出的。我需要一种将密码保存在加密文件中的方法,因为我需要记住许多密码,但记忆力却已大不如前。我知道有许多商用工具能够做到这一点,但我感到学习 .NET 中的一项新技术真的很有好处。
我用 Visual Basic® .NET 完成了一个简单而完整的程序,用于加密和解密文件,从中学到了许多知识。既然加密对于多种开发都是一个重要问题,本文就介绍一下如何构造这样的程序。
有各种低级别的技术可以用于加密,如 Microsoft Crypto API。而在 .NET 中,则是将这些复杂内容打包在各个 .NET 框架类中,并且由一个 System.Security 命名空间包含这些与加密相关的类。我们不可能查看该命名空间中的所有类,但通过分析一个最简单的、使用数据加密标准 (DES) 算法进行加密和解密的类,可以大概了解它们的工作原理。
正如前面提到的,我们要执行一个完整的加密和解密文件的过程,但首先需要解释一下该程序中涉及的许多基本概念。除有关密码的原理外,还有必要简单讨论一下 .NET 中的流,因为加密类是以流的形式实现的。
理解流
流是 .NET 中处理字节的基本概念。下面简单介绍一下其工作原理。
假设要读取一个文件,将所有大写字母更改为小写字母,然后将结果写入另一个文件。图 1 显示了要完成的各个步骤的关系图。
图 1:读取文件、处理内容并写回结果的过程
在 .NET 中,完成此过程的最好方法是使用流。“流”是一个对象,用于接收和/或发送信息字节。流有两种 - 后端流和过程流。
后端流
后端流从某个可以保存字节的位置获取字节或将字节存储到该位置。文件流就是一种后端流。文件流使用文件作为字节的后端存储,并读取或写入该文件。
文件流在 .NET 的 FileStream 类中实现,该类位于 System.IO 命名空间。FileStream 对象使用 Read 和 Write 方法访问文件。将 FileStream 对象附加到现有文件时,您可以使用 Read 方法,以一系列字节的形式获取文件内容。而使用 Write 方法时,FileStream 对象可以将一系列字节写入文件(现有文件或新文件)。FileStream 类还使用 Seek 方法来定位文件中的特定位置。
后端流的其他示例有网络流(将数据放到 TCP/IP 堆栈或从中获取数据)和内存流(使用内存作为临时后端)。它们的基本结构与 FileStream 对象相同,都使用 Read 和 Write 方法访问后端存储的字节。有些后端流(如网络流)不支持 Seek 方法,因为没有可供执行查找操作的永久存储内容。
过程流
过程流用于接收并处理字节,然后将字节写入其他流(通常是后端流)。例如,我们可以从名为 Stream 的 .NET 基类中继承,然后创建一个将大写字母更改为小写字母的过程流。再将这个流附加到任何后端流。现在,上图的关系可以表示为图 2。
图 2:使用流表示的读取文件、处理内容并写回结果的过程
我们的“变为小写”流类只在经常需要执行该操作时才有用。但这种流类可以对通过它的字节执行所需的各种操作。
.NET 中的加密
在 .NET 中,加密和解密是使用过程流来实现的。例如,加密的典型步骤为:
- 从某个输入流(例如,磁盘中的未加密文件)传入字节。
- 将字节送到加密流,加密流本身连接到某个输出流(例如,要保存加密数据的文件)。
- 加密流加密字节并自动将字节放到相关联的输出流中。
加密流被封装到一个名为 CryptoStream 的类(本文后面将详细介绍该类)。假设我们正在读取和写入磁盘文件,那么如果使用这一术语,则此过程的关系如图 3 所示。
图 3:加密文件的过程
加密类型
加密信息的方法已经有几百年的历史。小说家艾伦·坡就曾经涉足密码学,而设计和破解密码也曾经是第二次世界大战中的一项重要活动。然而,计算机的出现使密码学有了飞速发展。计算机强大的分析加密消息的能力迫使人们不断研究越来越难以破解的加密技术。
其结果是研究出了多种加密方法。.NET 中提供的常用方法包括:加密方法一般类型实现方法的 .NET 类数据加密标准 (DES)对称
(私钥)DESCryptoServiceProviderRC2 (RSA Data Security, Inc.)对称
(私钥)RC2CryptoServiceProviderRijndael对称
(私钥)RijndaelManagedTripleDES(在一行中使用三重 DES 加密)对称
(私钥)TripleDESCryptoServiceProvider数字签名算法非对称
(公钥)DSACryptoServiceProviderRSA(由 Rivest、Shamir 和 Adelman 发明,以他们名字的首字母命名)非对称
(公钥)RSACryptoServiceProvider
加密算法的一般类型有对称和非对称两种。对称算法使用相同的密钥来加密和解密数据。非对称算法使用一个公钥进行加密,而使用另一个密钥来解密。在本文最后,我们将继续介绍这一点。
如果只是使用加密方法,则不必详细了解其工作原理(谢天谢地,某些内容是相当复杂的);但如果要选择一种算法,则必须考虑以下三个主要因素:
- 破解使用该算法加密的消息的难度
- 算法的性能
- 密钥的安全性
有许多 Web 站点讨论了以上因素。对于初学者,以下两个网站比较适合:http://www.microsoft.com/china/security/ 和 Snake Oil FAQ http://www.interhack.net/people/cmcurtin/snake-oil-faq.html(英文)。
使用 .NET 加密类
本表列出的 .NET 类都在 System.Security.Cryptography 命名空间中,因此使用它们时必须引用 System.Security.dll。此外,使用一对 IMPORTS 语句来引用命名空间会使代码更加简洁:
Imports System.SecurityImports System.Security.Cryptography
上表中的类都和一个名为 CryptoStream 的一般密码流类一起工作。这样便可以仅使用一个能实现多种加密的类来处理流操作。您甚至可以创建自己的加密类并将其插入 CryptoStream 中(尽管其安全性不太可能与上表列出的类相比)。
在我们的示例中,我们使用的加密方法可能是 .NET 中最简单的,即使用 DSE 算法进行对称密钥加密。实现 DES 的过程流被称为 DESCryptoServiceProvider。
大多数对称算法要求有两个单独的字节数组,用于加密过程。第一个是密钥。对于 DES,密钥是 8 个字节,而其他算法使用的字节数则不同。
必须将此私钥以一种安全的方式传递给解密文件的人,如果私钥泄露,加密信息也将泄露。但即使密钥不泄露,DES 加密也正常工作,这种加密方法也远远称不上是最安全的算法。
要加密一个信息块(通常是 8 个字节),需要同时使用密钥和上一个块的加密结果,也就是说,具有相同原始字符的块在加密后不会得出相同的结果。这样做的优点是,重复的块不会提供线索而使加密的破解变得更容易。
不过,第一个块没有前导块作为加密的输入。如果第一个块包含已知信息(如网络标头),则对第一个块实施反向工程,就会很容易地获得密钥。
为防止这种破解方法,DES 使用所谓的“初始化向量”。这是另一个字节数组,长度与密钥相同。将其与密钥一起使用,进一步加密 8 个字节的第一个块。还有其他几种对称算法也使用初始化向量。
创建密钥
如果使用随机密钥,则对称加密算法最安全。所以,生成密钥的最好方法是使用随机过程来获得所需的 8 个字节。但是,8 个随机字节并不容易记住。在下面的示例中,我们使用“密码”来生成密钥。简单地说,密码是一个 8 字符的字符串,使用字符的 ASCII 值来初始化构成密钥的字节数组。
我们需要两个这样的密码:一个用于密钥,另一个用于初始化向量。这还远不是生成密钥的最安全方法,但比较适合我们的示例;而且对于常规的使用,它提供了适当的安全性级别。
加密/解密程序
我们已经介绍了相关概念,现在可以开始创建加密和解密文件的程序了。我们将其设计为一个 Windows 窗体应用程序。
在 Visual Studio 中创建一个新的 Windows 窗体应用程序。要访问密码类,请转至 Project | Add Reference(项目|添加引用),添加对 System.Security 的引用。
项目中的窗体需要相互并排的四个标签和四个文本框,靠近底部有两个按钮,底边是一个状态栏。完成后,窗体应如图 4 所示。
图 4:加密/解密程序的窗体布局
使用以下名称从上到下设置文本框:
- txtUnencryptedFile
- txtEncryptedFile
- txtKeyPassword
- txtIVPassword
将各个文本框的 Text 属性设置为空。将状态栏命名为 sbEncryptionStatus。
将按钮命名为 btnEncrypt 和 btnDecrypt,并将它们的 Text 属性分别更改为 Encrypt 和 Decrypt。为 btnEncrypt 按钮添加以下代码:
Dim byteKey() As BytebyteKey = GetKeyByteArray(txtKeyPassword.Text)Dim byteInitializationVector() As BytebyteInitializationVector = GetKeyByteArray(txtIVPassword.Text)EncryptOrDecryptFile(txtUnencryptedFile.Text, _ txtEncryptedFile.Text, _ byteKey, byteInitializationVector, _ CryptoAction.actionEncrypt)
为 btnDecrypt 按钮添加以下代码:
Dim byteKey() As BytebyteKey = GetKeyByteArray(txtKeyPassword.Text)Dim byteInitializationVector() As BytebyteInitializationVector = GetKeyByteArray(txtIVPassword.Text)EncryptOrDecryptFile(txtEncryptedFile.Text, _ txtUnencryptedFile.Text, _ byteKey, byteInitializationVector, _ CryptoAction.actionDecrypt)
您会注意到这两个按钮相关的代码很类似。它们使用相同的函数获取密钥和初始化向量的数组,并使用相同的函数(名为 EncryptOrDecryptFile)加密或解密文件。使用 EncryptOrDecryptFile 时的唯一区别是文本框中输入和输出文件的文件名正好相反,并且动作(加密或解密)不同。
该动作被指定为 CryptoAction 枚举类型,所以需要定义枚举。将此代码添加到 Inherits System.Windows.Forms.Form 行的下面:
Private Enum CryptoAction actionEncrypt = 1 actionDecrypt = 2End Enum
还需要在模块顶部添加语句,以便轻松地引用密码类和流类。以下是所需的代码行:
Imports System.Security.CryptographyImports System.SecurityImports System.IO
到目前为止,代码都比较简单。现在我们看看怎样生成密钥。下面是一个将密码变成字节数组的函数,应当将它添加到窗体代码中:
Private Function GetKeyByteArray(ByVal sPassword As String) As Byte() Dim byteTemp(7) As Byte sPassword = sPassword.PadRight(8) ' 确保是 8 个字符 Dim iCharIndex As Integer For iCharIndex = 0 To 7 byteTemp(iCharIndex) = Asc(Mid$(sPassword, iCharIndex + 1, 1)) Next Return byteTempEnd Function
这也是一段很直观的代码。Visual Basic 6.0 开发人员应当注意对字符串的 PadRight 方法(取代 Visual Basic 6.0 中的等效字符串处理代码)的使用,以确保长度正确。
下面是关键内容。插入下面的函数以处理加密和解密:
Private Sub EncryptOrDecryptFile(ByVal sInputFile As String, _ ByVal sOutputFile As String, _ ByVal byteDESKey() As Byte, _ ByVal byteDESIV() As Byte, _ ByVal Direction As CryptoAction) ' 创建处理输入和输出文件的文件流。 Dim fsInput As New FileStream(sInputFile, _ FileMode.Open, FileAccess.Read) Dim fsOutput As New FileStream(sOutputFile, _ FileMode.OpenOrCreate, FileAccess.Write) fsOutput.SetLength(0) ' 加密/解密过程中需要的变量 Dim byteBuffer(4096) As Byte ' 保存字节块以进行处理 Dim nBytesProcessed As Long = 0 ' 运行对加密字节的计数 Dim nFileLength As Long = fsInput.Length Dim iBytesInCurrentBlock As Integer Dim desProvider As New DESCryptoServiceProvider() Dim csMyCryptoStream As CryptoStream Dim sDirection As String ' 设置为加密或解密 Select Case Direction Case CryptoAction.actionEncrypt csMyCryptoStream = New CryptoStream(fsOutput, _ desProvider.CreateEncryptor(byteDESKey, byteDESIV), _ CryptoStreamMode.Write) sDirection = "加密" Case CryptoAction.actionDecrypt csMyCryptoStream = New CryptoStream(fsOutput, _ desProvider.CreateDecryptor(byteDESKey, byteDESIV), _ CryptoStreamMode.Write) sDirection = "解密" End Select sbEncryptionStatus.Text = sDirection + "正在启动..." ' 从输入文件读取,然后加密或解密 ' 并写入输出文件。 While nBytesProcessed < nFileLength iBytesInCurrentBlock = fsInput.Read(byteBuffer, 0, 4096) csMyCryptoStream.Write(byteBuffer, 0, iBytesInCurrentBlock) nBytesProcessed = nBytesProcessed + CLng(iBytesInCurrentBlock) sbEncryptionStatus.Text = sDirection + _ "正在处理 - 已处理字节数 - " + _ nBytesProcessed.ToString End While sbEncryptionStatus.Text = "完成" + sDirection + _ "。处理的字节总数 - " + nBytesProcessed.ToString csMyCryptoStream.Close() fsInput.Close() fsOutput.Close()End Sub
现在我们具体说明以上代码。
第一段创建文件流对象(名为 fsInput 和 fsOutput),用于从正在读取的文件获得输入,再输出到一个新文件中。然后是函数的其余部分中需要的几个变量和对象的声明。所声明的元素如下:
- byteBuffer:字节数组,用于处理当前数据块。通过读取输入文件来填充该数组,然后将其传递到 CryptoStream 对象进行加密。后面的代码有一个读取输入文件的循环,以大小为 4096 字节的块提取文件内容,并放到 byteBuffer 中。
- nBytesProcessed:到目前为止处理的输入文件的总字节数。
- nFileLength:输入文件的长度。
- iBytesInCurrentBlock:在循环的特定迭代操作中处理的字节数。除最后一次外,每次迭代都是 4096 字节。在最后一次迭代中,其字节数是文件的最后一个块中的剩余字节数(通常小于 4096)。
- desProvider:将其插入 CryptoStream 中以提供要使用的加密/解密功能。
- csMyCryptoStream:是用于加密或解密的 CryptoStream 对象。
- sDirection:是 CryptoAction 值(actionEncrypt 或 actionDecrypt),表明通过执行此函数所要完成的操作。
接下来的一段是 Select Case,根据我们执行该函数所要完成的操作来设置为加密或解密。DESCryptoServiceProvider 可以分别使用 CreateEncryptor 或 CreateDecryptor 方法创建加密器或解密器。这用于实例化我们要使用的 CryptoStream 对象,并命名为 csMyCryptoStream。csMyCryptoStream 对象还需要知道使用哪个流进行输出。为此,在实例化 csMyCryptoStream 过程中,加密和解密都指定 fsOutput 流。
我们还设置了一个用于状态栏消息的字符串,其值为“加密”或“解密”。
最后一段真正执行加密和解密。While 循环从输入文件读取数据,一次读取一个块。然后使用其 Write 方法将块写入到 csMyCryptoStream 中。之后,csMyCryptoStream 自动执行加密或解密,并将结果写入到所附加的文件流 fsOutput 中。然后,更新处理的字节总数和状态栏消息,并执行循环以处理另一个块。
循环完成后,更新状态栏文本并关闭流对象,整个过程便告结束。
现在我们可以测试该项目。要进行正确的测试,加密的文件大小要适当。我使用从 Project Gutenberg(http://promo.net/pg/ [英文])获得的一个文本文件,该 Web 站点提供各种可以下载且不受版权限制的文本文件。我选择的文件是柯南·道尔著的《福尔摩斯探案集》。文本文件长度为 573 KB。
在 Unencrypted File(未加密文件)文本框中,输入您选择的需要加密的文件的路径名;并在 Encrypted File(已加密文件)文本框中,输入加密文件的路径名。还需要为密钥和初始化向量构造密码。
在测试时,我用的是 700-MHz 的 Pentium III 计算机,加密大小为 573 KB 的《福尔摩斯探案集》用了不到两秒钟时间。这展示了 DES 算法的优势之一,即其优异的性能。
如果在测试后打开输出文件,会看到文件已被完全加密。现在,使用相同的密码将它解密到另一个文件中。您可能希望确保新的解密文件与原文件完全相同。在 Microsoft Windows® 中有一个命令行实用程序,名为 FC(文件比较),可以用它来进行检验。
很显然,如果您在解密时更改了密码,解密将不起作用。然而,如果在解密时更改初始化向量,则会发生有趣的事情。除了前 8 个字符外,文件被正常解密。也就是说,初始化向量只能保护第一个块。
总结
如上所述,DES 算法只是一种选择。而最灵活的选择之一是使用公钥加密系统,也称为非对称加密。在这种技术中,加密和解密使用不同的密钥。加密密钥是公开的,而解密密钥则是保密的,只有需要执行解密的人才知道。人人都可以使用加密密钥进行加密,但只能使用解密密钥进行解密。
公钥加密的最大缺点是其性能较差。对称算法对处理能力的要求比公钥算法低得多。但是,公钥加密令所有人(即使是您不认识的人)都能加密文件并将它发送给您,这会给您提供更大的灵活性。
无论选择哪种加密技术,使用 .NET 密码类都能使操作更容易。正如本文示例所示,您只需提供必要的密钥和其他参数,然后将密码类插入相应的流即可。这为创建使用加密技术的 Visual Basic .NET 应用程序提供了更大方便。