☰
C#手写DES文件加密算法:从置换表到S盒的完整实现与源码
2026/10/21 8:41:05 网站建设 项目流程

把“C#实现DES文件加密算法,附完整源码”这个需求接下来之后,我的第一反应是:都2025年了,还会有正经项目要手写DES?但转念一想,DES在密码学教材里的地位,和数据结构里的快排、操作系统里的LRU差不多——你可能不会在正式项目里真的手写它,但只要你把它的置换表、S盒、子密钥调度这些部件全部吃透,再回头去看AES、看祖传代码里的3DES兼容逻辑,会轻松非常多。这篇博文就把我手写DES并用它做实文件加解密的完整思路和源码放出来,代码可以直接跑。它适合三类人:正在做课程设计或结业项目的开发者、需要维护老系统里DES兼容模块的工程师、以及单纯想让密码学基础更扎实而不是停留在“会用库”层面的技术爱好者。

我先把话说在前面:这篇文章里的源码是“从零实现”,全程没有调用系统自带的对称加密类,所有轮函数、置换、S盒替换都是手写的。这么做当然不是为了在生产环境取代AES,而是为了让你在调试过程中真正理解一个分组密码到底是怎么把明文搅成密文的。

1. DES文件加密项目的核心思路与算法拆解

1.1 为什么还需要亲手写一次DES

DES全称Data Encryption Standard,1977年被正式采纳为数据加密标准。它的密钥是64位,其中有效密钥56位,外加8位奇偶校验位。放在今天,56位密钥用专用硬件暴力破解已经不是新闻,所以但凡敏感数据,生产环境没人会直接用DES。但为什么教材、开源项目、老系统都在讲它?因为DES是Feistel网络最经典的样本,它把分组密码的设计思想浓缩得非常清晰:轮函数、密钥扩展、S盒非线性替换、扩散与混淆。理解了这些,后面学AES时你会有一种“看到亲戚”的感觉。

我这个项目的目标很简单:用C#把DES算法完整实现一遍,再用它处理真实文件,支持文件流式读取、PKCS7填充、ECB/CBC两种分组模式。也就是说,它不是只加密一个8字节数组的玩具,而是能加密任意大小文件的完整工具。

1.2 一次块加密的完整流程:从IP置换到逆IP

DES一次处理8个字节,也就是64位明文。流程可以分成四步。

第一步是初始置换IP。64位数据按照一个固定表重新排列比特位置,表的定义是公开标准,不用自己发明。这一步不改变数据内容,只改变顺序。

第二步是把置换后的64位分成左右两半,各32位,记为L0和R0。然后进入16轮迭代。每一轮做的事情是:右边32位先经过扩展置换E变成48位,和这一轮的子密钥(也是48位)做异或,再把48位分成8组、每组6位,分别送进8个S盒。每个S盒输入6位、输出4位,8个S盒合起来输出32位。这32位再经过P置换,最后和左边32位异或,得到新的右半部分,而原来的右半部分直接变成新的左半部分。

这个过程可以这样理解:每一轮都在用右半边“搅拌”左半边,而搅拌的配方由子密钥和S盒决定。16轮下来,左边和右边的关系已经被充分打散。

第三步是16轮结束后,做一次“最后一轮不交换”的处理,把R16和L16拼接成64位。

第四步是逆初始置换IP-1,把替换后的64位换回正常的位序,得到密文。

1.3 子密钥调度:从64位密钥到16个48位子密钥

DES的每一轮用到的子密钥不一样,这些子密钥并不是独立生成的,而是从同一个64位密钥里“派生”出来的。流程是这样的:

  • 64位密钥先经过PC-1置换,去掉8个奇偶校验位,得到56位。
  • 56位分成C0和D0两组,每组28位。
  • 接下来16轮循环,每轮先把C和D各自循环左移特定位数,左移位数由标准表决定(第一轮移1位,第二轮移1位,第三轮起大部分是2位,最后一轮又是1位)。
  • 把左移后的C和D拼成56位,再经过PC-2置换,从中取48位,作为这一轮的子密钥。

解密时只需要把子密钥的使用顺序反过来:加密用K1、K2……K16,解密就用K16、K15……K1。这一切之所以可行,全靠Feistel结构本身的对称性,这也是DES能在硬件时代被广泛实现的重要原因。

1.4 为什么加密和解密可以共用一套核心代码

Feistel网络的精妙之处在于:轮函数F不需要是可逆的。因为每一轮加密做的是“R和F(R, K)异或”,而解密时做的是“R和同一个F(R, K)异或”,两次异或互相抵消。所以我的核心类DesCore里只需要一份ProcessBlock方法,通过控制子密钥的顺序和拼接顺序,就能同时处理加密和解密。代码少了一半,出错概率也小了一半。

2. C#实现前的技术准备与关键选型

2.1 项目结构与目标框架选择

我用的环境是.NET 6/8控制台应用,目标框架net8.0。之所以选控制台而不是类库,是因为这个项目需要直接演示加密解密流程,命令行参数跑起来最直观。

整个项目只有三个文件:

  • DesCore.cs:DES算法核心,包括所有置换表、S盒、子密钥生成和单块加解密。
  • DesFileProcessor.cs:文件封装层,处理文件流读取、分组填充、ECB/CBC模式选择。
  • Program.cs:程序入口,提供命令行调用和自检流程。

这种拆分的好处是逻辑清晰。DesCore不关心文件是什么,它只负责处理8字节块;DesFileProcessor不关心算法细节,它负责把文件切成块、决定用什么模式、把填充做好。后续你想换成AES,只需要替换DesCore为AesCore,文件层的代码几乎不用动。

2.2 字节数组和64位整数的互相转换

DES处理的数据是64位一组,C#里最顺手的表示方式就是ulong。文件读出来的数据是byte[],所以在核心类里我写了两个转换函数:一个把8字节按大端方式拼成ulong,一个把ulong转回8字节。

为什么强调大端?因为DES标准中,置换表里的比特位编号是从最高位开始的。也就是说64位块的第1位是第1个字节的最高位,第64位是第8个字节的最低位。只要我在字节数组和ulong之间用统一的大端转换,后面所有置换逻辑都会顺畅。

2.3 ECB和CBC:文件加密必须考虑分组模式

分组密码默认只加密一个固定长度的块,但文件通常不止8字节,所以要把文件切成一堆8字节块,一块一块加密。这里立刻面临选择:每块独立加密,还是块与块之间建立关联?

最简单的ECB模式就是每块独立加密。它的缺点是:相同的明文块会产生相同的密文块。如果你加密的是一张位图、一份Word文档,这种规律性会被攻击者直接利用。所以在文件加密里,我强烈建议用CBC模式。

CBC模式的做法是:第一个明文块先和初始向量IV做异或,再进入DES加密,得到密文块;第二个明文块先和第一个密文块异或,再加密;以此类推。解密时反向操作。这样做的好处是,同样内容的两个明文块,只要它们所处的位置不同或者前一个密文块不同,最终密文就不同。

我在DesFileProcessor里同时实现了两种模式,默认用CBC。CBC模式下,加密文件开头会写入一个8字节随机IV,解密时先读取它再解密后续数据。这样每次加密同一个文件,得到的密文文件都不一样,更接近真实需求。

2.4 PKCS7填充:文件尾部的最后一块怎么处理

DES要求输入必须是8字节的整数倍,但文件长度不可能总是8的倍数。解决方法是填充。

我采用标准PKCS7填充:如果最后一块还差N个字节才满8字节,就补上N个字节,每个字节的值都是N。举个例子,最后一块只剩3个字节,那就补5个0x05;如果文件长度恰好是8的倍数,按PKCS7的规则,还得在最后额外补一个完整的8字节块,每个字节都是0x08。这样解密时看到最后一个字节是几,就知道该去掉几个填充字节,不会误删真正的文件内容。

填充逻辑最容易被忽视的坑就是“恰好是8的倍数”这种情况。如果你忘了追加填充块,解密后最后一块就会有8个0x08尾巴;如果你不加判断地直接去掉填充字节,恰好又是用户文件里真实存在的0x08内容,就会误删数据。所以我的文件处理器里用了“延迟一块”的方式处理这种边界情况。

3. 完整源码实现:从核心算法到文件封装

3.1 置换表和S盒的定义

DES的全部秘密都在标准表格里。IP表64个值,逆IP表64个值,E表48个值,P表32个值,PC-1表56个值,PC-2表48个值,移位表16个值,再加上8个S盒共256个值。手抄这些表的时候一定要细心,我调试过程中出现解密结果不对,最后定位到原因就是S盒某一行抄错了一个数字。

下面是DesCore.cs的完整源码。我把它放在一个类里,结构顺序是:置换表定义、S盒定义、构造函数、通用置换函数、子密钥生成、Feistel轮函数、单块加解密。

using System; namespace DesFileEncryptor { /// <summary> /// DES算法核心,完全手写实现,不依赖系统对称加密类。 /// </summary> public class DesCore { // 初始置换表 IP,输入64位,输出64位 private static readonly int[] IP_Table = { 58, 50, 42, 34, 26, 18, 10, 2, 60, 52, 44, 36, 28, 20, 12, 4, 62, 54, 46, 38, 30, 22, 14, 6, 64, 56, 48, 40, 32, 24, 16, 8, 57, 49, 41, 33, 25, 17, 9, 1, 59, 51, 43, 35, 27, 19, 11, 3, 61, 53, 45, 37, 29, 21, 13, 5, 63, 55, 47, 39, 31, 23, 15, 7 }; // 逆初始置换表 IP-1 private static readonly int[] IP_INV_Table = { 40, 8, 48, 16, 56, 24, 64, 32, 39, 7, 47, 15, 55, 23, 63, 31, 38, 6, 46, 14, 54, 22, 62, 30, 37, 5, 45, 13, 53, 21, 61, 29, 36, 4, 44, 12, 52, 20, 60, 28, 35, 3, 43, 11, 51, 19, 59, 27, 34, 2, 42, 10, 50, 18, 58, 26, 33, 1, 41, 9, 49, 17, 57, 25 }; // 扩展置换表 E,输入32位,输出48位 private static readonly int[] E_Table = { 32, 1, 2, 3, 4, 5, 4, 5, 6, 7, 8, 9, 8, 9, 10, 11, 12, 13, 12, 13, 14, 15, 16, 17, 16, 17, 18, 19, 20, 21, 20, 21, 22, 23, 24, 25, 24, 25, 26, 27, 28, 29, 28, 29, 30, 31, 32, 1 }; // P置换表,输入32位,输出32位 private static readonly int[] P_Table = { 16, 7, 20, 21, 29, 12, 28, 17, 1, 15, 23, 26, 5, 18, 31, 10, 2, 8, 24, 14, 32, 27, 3, 9, 19, 13, 30, 6, 22, 11, 4, 25 }; // 密钥置换 PC-1,输入64位,输出56位 private static readonly int[] PC1_Table = { 57, 49, 41, 33, 25, 17, 9, 1, 58, 50, 42, 34, 26, 18, 10, 2, 59, 51, 43, 35, 27, 19, 11, 3, 60, 52, 44, 36, 63, 55, 47, 39, 31, 23, 15, 7, 62, 54, 46, 38, 30, 22, 14, 6, 61, 53, 45, 37, 29, 21, 13, 5, 28, 20, 12, 4 }; // 密钥置换 PC-2,输入56位,输出48位 private static readonly int[] PC2_Table = { 14, 17, 11, 24, 1, 5, 3, 28, 15, 6, 21, 10, 23, 19, 12, 4, 26, 8, 16, 7, 27, 20, 13, 2, 41, 52, 31, 37, 47, 55, 30, 40, 51, 45, 33, 48, 44, 49, 39, 56, 34, 53, 46, 42, 50, 36, 29, 32 }; // 16轮循环左移位数 private static readonly int[] ROTATIONS = { 1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1 }; // 8个S盒,每个4行16列 private static readonly int[,,] S_BOX = new int[8, 4, 16] { { { 14, 4, 13, 1, 2, 15, 11, 8, 3, 10, 6, 12, 5, 9, 0, 7 }, { 0, 15, 7, 4, 14, 2, 13, 1, 10, 6, 12, 11, 9, 5, 3, 8 }, { 4, 1, 14, 8, 13, 6, 2, 11, 15, 12, 9, 7, 3, 10, 5, 0 }, { 15, 12, 8, 2, 4, 9, 1, 7, 5, 11, 3, 14, 10, 0, 6, 13 } }, { { 15, 1, 8, 14, 6, 11, 3, 4, 9, 7, 2, 13, 12, 0, 5, 10 }, { 3, 13, 4, 7, 15, 2, 8, 14, 12, 0, 1, 10, 6, 9, 11, 5 }, { 0, 14, 7, 11, 10, 4, 13, 1, 5, 8, 12, 6, 9, 3, 2, 15 }, { 13, 8, 10, 1, 3, 15, 4, 2, 11, 6, 7, 12, 0, 5, 14, 9 } }, { { 10, 0, 9, 14, 6, 3, 15, 5, 1, 13, 12, 7, 11, 4, 2, 8 }, { 13, 7, 0, 9, 3, 4, 6, 10, 2, 8, 5, 14, 12, 11, 15, 1 }, { 13, 6, 4, 9, 8, 15, 3, 0, 11, 1, 2, 12, 5, 10, 14, 7 }, { 1, 10, 13, 0, 6, 9, 8, 7, 4, 15, 14, 3, 11, 5, 2, 12 } }, { { 7, 13, 14, 3, 0, 6, 9, 10, 1, 2, 8, 5, 11, 12, 4, 15 }, { 13, 8, 11, 5, 6, 15, 0, 3, 4, 7, 2, 12, 1, 10, 14, 9 }, { 10, 6, 9, 0, 12, 11, 7, 13, 15, 1, 3, 14, 5, 2, 8, 4 }, { 3, 15, 0, 6, 10, 1, 13, 8, 9, 4, 5, 11, 12, 7, 2, 14 } }, { { 2, 12, 4, 1, 7, 10, 11, 6, 8, 5, 3, 15, 13, 0, 14, 9 }, { 14, 11, 2, 12, 4, 7, 13, 1, 5, 0, 15, 10, 3, 9, 8, 6 }, { 4, 2, 1, 11, 10, 13, 7, 8, 15, 9, 12, 5, 6, 3, 0, 14 }, { 11, 8, 12, 7, 1, 14, 2, 13, 6, 15, 0, 9, 10, 4, 5, 3 } }, { { 12, 1, 10, 15, 9, 2, 6, 8, 0, 13, 3, 4, 14, 7, 5, 11 }, { 10, 15, 4, 2, 7, 12, 9, 5, 6, 1, 13, 14, 0, 11, 3, 8 }, { 9, 14, 15, 5, 2, 8, 12, 3, 7, 0, 4, 10, 1, 13, 11, 6 }, { 4, 3, 2, 12, 9, 5, 15, 10, 11, 14, 1, 7, 6, 0, 8, 13 } }, { { 4, 11, 2, 14, 15, 0, 8, 13, 3, 12, 9, 7, 5, 10, 6, 1 }, { 13, 0, 11, 7, 4, 9, 1, 10, 14, 3, 5, 12, 2, 15, 8, 6 }, { 1, 4, 11, 13, 12, 3, 7, 14, 10, 15, 6, 8, 0, 5, 9, 2 }, { 6, 11, 13, 8, 1, 4, 10, 7, 9, 5, 0, 15, 14, 2, 3, 12 } }, { { 13, 2, 8, 4, 6, 15, 11, 1, 10, 9, 3, 14, 5, 0, 12, 7 }, { 1, 15, 13, 8, 10, 3, 7, 4, 12, 5, 6, 11, 0, 14, 9, 2 }, { 7, 11, 4, 1, 9, 12, 14, 2, 0, 6, 10, 13, 15, 3, 5, 8 }, { 2, 1, 14, 7, 4, 10, 8, 13, 15, 12, 9, 0, 3, 5, 6, 11 } } }; private byte[] _key; private ulong[] _subKeys = new ulong[16]; /// <summary> /// 构造一个DES核心实例,密钥必须是8字节。 /// </summary> public DesCore(byte[] key) { if (key == null || key.Length != 8) { throw new ArgumentException("DES密钥长度必须为8字节(64位)。"); } _key = (byte[])key.Clone(); GenerateSubKeys(); } /// <summary> /// 通用置换函数。table里的值从1开始编号,1表示输入值的最高位。 /// </summary> private static ulong Permute(ulong input, int inputBits, int[] table, int outputBits) { ulong result = 0; for (int i = 0; i < outputBits; i++) { int shift = inputBits - table[i]; result = (result << 1) | ((input >> shift) & 1UL); } return result; } /// <summary> /// 将8字节数组按大端序转为ulong。 /// </summary> private static ulong BlockToUInt64(byte[] block) { ulong value = 0; for (int i = 0; i < 8; i++) { value = (value << 8) | block[i]; } return value; } /// <summary> /// 将ulong按大端序转为8字节数组。 /// </summary> private static byte[] UInt64ToBlock(ulong value) { byte[] block = new byte[8]; for (int i = 7; i >= 0; i--) { block[i] = (byte)(value & 0xFF); value >>= 8; } return block; } /// <summary> /// 28位循环左移。 /// </summary> private static ulong LeftShift28(ulong value, int shifts) { return ((value << shifts) | (value >> (28 - shifts))) & 0x0FFFFFFF; } /// <summary> /// 从64位密钥生成16轮48位子密钥。 /// </summary> private void GenerateSubKeys() { ulong key64 = BlockToUInt64(_key); ulong permutedKey = Permute(key64, 64, PC1_Table, 56); ulong C = (permutedKey >> 28) & 0x0FFFFFFF; ulong D = permutedKey & 0x0FFFFFFF; for (int i = 0; i < 16; i++) { C = LeftShift28(C, ROTATIONS[i]); D = LeftShift28(D, ROTATIONS[i]); ulong combined = (C << 28) | D; _subKeys[i] = Permute(combined, 56, PC2_Table, 48); } } /// <summary> /// Feistel轮函数:R从32位扩展到48位,与子密钥异或, /// 经过8个S盒后压回32位,再做P置换。 /// </summary> private static ulong Feistel(ulong r, ulong subKey) { ulong expanded = Permute(r, 32, E_Table, 48); ulong xored = expanded ^ subKey; ulong output = 0; for (int i = 0; i < 8; i++) { int shift = (7 - i) * 6; int sixBits = (int)((xored >> shift) & 0x3F); int row = ((sixBits & 0x20) >> 4) | (sixBits & 0x01); int col = (sixBits >> 1) & 0x0F; int val = S_BOX[i, row, col]; output = (output << 4) | (ulong)val; } return Permute(output, 32, P_Table, 32); } /// <summary> /// 处理一个8字节块。encrypt为true加密,false解密。 /// </summary> private byte[] ProcessBlock(byte[] inputBlock, bool encrypt) { ulong block = BlockToUInt64(inputBlock); block = Permute(block, 64, IP_Table, 64); ulong L = (block >> 32) & 0xFFFFFFFF; ulong R = block & 0xFFFFFFFF; for (int i = 0; i < 16; i++) { int keyIndex = encrypt ? i : 15 - i; ulong newL = R; ulong newR = L ^ Feistel(R, _subKeys[keyIndex]); L = newL; R = newR; } // 最后一轮不交换,输出R16在前、L16在后 ulong combined = (R << 32) | L; combined = Permute(combined, 64, IP_INV_Table, 64); return UInt64ToBlock(combined); } public byte[] EncryptBlock(byte[] block) { return ProcessBlock(block, true); } public byte[] DecryptBlock(byte[] block) { return ProcessBlock(block, false); } } }

这段代码里有一个地方值得单独强调:ProcessBlock方法里的最后拼接顺序。很多DES实现资料会写“加密第16轮后交换左右两部分”,实际上正确做法是R16在左、L16在右,直接拼接成64位,再进行逆初始置换。我最初写反了,导致自检失败,后来对照标准测试向量才定位到问题。

3.2 文件封装层:把算法落到真实文件上

核心算法解决好之后,剩下的就是工程问题。DesFileProcessor需要处理三件事:文件流按8字节分块、PKCS7填充、CBC模式的IV头处理。

我采用“延迟一个块”的方式来处理文件末尾。为什么需要延迟?因为当读到恰好8字节时,系统并不知道它到底是不是最后一块。只有再尝试读一次,如果读不到数据了,才能确认这是最后一块,此时还要额外补一个完整填充块。所以代码里用一个pendingBlock变量把上一个完整块先扣在手里,当下一次读取确认没有更多数据时,再把它加密并追加填充块。这个方案逻辑上比较严谨,不会出现加完密还要回头改文件的尴尬。

下面是DesFileProcessor.cs的完整源码。

using System; using System.IO; using System.Security.Cryptography; namespace DesFileEncryptor { /// <summary> /// 文件级DES加解密封装,支持ECB/CBC模式和PKCS7填充。 /// </summary> public class DesFileProcessor { private readonly DesCore _des; private readonly bool _useCbc; private readonly byte[] _iv; /// <summary> /// 构造文件处理器。 /// </summary> /// <param name="key">8字节密钥</param> /// <param name="useCbc">是否使用CBC模式,默认true</param> /// <param name="iv">8字节初始向量。如果为null,CBC模式下自动生成随机IV并写入文件头。</param> public DesFileProcessor(byte[] key, bool useCbc = true, byte[] iv = null) { _des = new DesCore(key); _useCbc = useCbc; if (_useCbc && iv != null) { if (iv.Length != 8) { throw new ArgumentException("IV长度必须为8字节。"); } _iv = (byte[])iv.Clone(); } } /// <summary> /// 生成8字节随机IV。 /// </summary> private static byte[] GenerateRandomIv() { byte[] iv = new byte[8]; RandomNumberGenerator.Fill(iv); return iv; } /// <summary> /// CBC模式加密块:先异或IV状态,再加密,然后更新IV状态。 /// </summary> private byte[] EncryptBlock(byte[] block, byte[] ivState) { if (_useCbc) { for (int i = 0; i < 8; i++) { block[i] ^= ivState[i]; } } byte[] cipher = _des.EncryptBlock(block); if (_useCbc) { Buffer.BlockCopy(cipher, 0, ivState, 0, 8); } return cipher; } /// <summary> /// CBC模式解密块:先解密,再异或IV状态,然后更新IV状态为原始密文。 /// </summary> private byte[] DecryptBlock(byte[] block, byte[] ivState) { byte[] originalCipher = (byte[])block.Clone(); byte[] plain = _des.DecryptBlock(block); if (_useCbc) { for (int i = 0; i < 8; i++) { plain[i] ^= ivState[i]; } Buffer.BlockCopy(originalCipher, 0, ivState, 0, 8); } return plain; } /// <summary> /// 加密文件。 /// </summary> public void EncryptFile(string sourcePath, string destPath) { using var source = File.OpenRead(sourcePath); using var dest = File.Create(destPath); byte[] ivState = null; if (_useCbc) { ivState = _iv ?? GenerateRandomIv(); dest.Write(ivState, 0, 8); } byte[] buffer = new byte[8]; byte[] pendingBlock = null; int n; while ((n = source.Read(buffer, 0, 8)) > 0) { if (n == 8) { if (pendingBlock != null) { byte[] cipher = EncryptBlock(pendingBlock, ivState); dest.Write(cipher, 0, cipher.Length); } pendingBlock = (byte[])buffer.Clone(); } else { // 最后一块不足8字节,按PKCS7填充 byte[] padded = new byte[8]; Buffer.BlockCopy(buffer, 0, padded, 0, n); int padLen = 8 - n; for (int i = n; i < 8; i++) { padded[i] = (byte)padLen; } if (pendingBlock != null) { byte[] cipher = EncryptBlock(pendingBlock, ivState); dest.Write(cipher, 0, cipher.Length); pendingBlock = null; } byte[] finalCipher = EncryptBlock(padded, ivState); dest.Write(finalCipher, 0, finalCipher.Length); } } // 文件大小恰好是8的倍数,需要额外补一个完整填充块 if (pendingBlock != null) { byte[] lastPlain = new byte[8]; for (int i = 0; i < 8; i++) { lastPlain[i] = 0x08; } byte[] cipher = EncryptBlock(pendingBlock, ivState); dest.Write(cipher, 0, cipher.Length); byte[] padCipher = EncryptBlock(lastPlain, ivState); dest.Write(padCipher, 0, padCipher.Length); } } /// <summary> /// 解密文件。 /// </summary> public void DecryptFile(string sourcePath, string destPath) { using var source = File.OpenRead(sourcePath); using var dest = File.Create(destPath); byte[] ivState = null; if (_useCbc) { ivState = new byte[8]; if (source.Read(ivState, 0, 8) != 8) { throw new InvalidDataException("密文文件缺少8字节IV头。"); } } if (source.Length % 8 != 0) { throw new InvalidDataException("密文文件长度不是8的倍数,数据可能已损坏。"); } long totalBlocks = source.Length / 8; byte[] block = new byte[8]; for (long i = 0; i < totalBlocks; i++) { int read = source.Read(block, 0, 8); if (read != 8) { throw new InvalidDataException("读取密文块失败。"); } byte[] plain = DecryptBlock(block, ivState); if (i == totalBlocks - 1) { int padLen = plain[7]; if (padLen < 1 || padLen > 8) { throw new InvalidDataException("填充字节非法,密钥或数据可能错误。"); } int writeLen = 8 - padLen; if (writeLen > 0) { dest.Write(plain, 0, writeLen); } } else { dest.Write(plain, 0, 8); } } } } }

这里有一个细节要提醒:CBC模式下解密时,IV状态更新用的是“原始密文块”,不是解密后的明文块。我写DecryptBlock时先把block克隆了一份,就是干这个用的。如果你不小心用解密后的明文去更新IV状态,从第二块开始就全乱了。

3.3 程序入口、密钥规范化和自检

Program.cs负责三个事情:解析命令行参数、把用户输入的密钥字符串规范成8字节数组、执行标准测试向量自检。

DES密钥虽然名义上是8字节,但每个字节的最高位是奇偶校验位,实际有效的只有56位。我在NormalizeKey方法里做了这样的处理:如果密钥字符串转成UTF-8字节后正好8字节,直接用;不足8字节,右侧补0;超过8字节,截断到8字节。这种做法适合教学演示,但如果是正式系统,密钥应该来自安全的随机源或密钥管理系统,而不是用户随便打的一段话。

using System; using System.Linq; using System.Text; namespace DesFileEncryptor { public static class Program { public static void Main(string[] args) { RunSelfTest(); if (args.Length < 4) { PrintUsage(); return; } string action = args[0].ToLowerInvariant(); string sourcePath = args[1]; string destPath = args[2]; string keyText = args[3]; bool useCbc = args.Length < 5 || args[4].Equals("cbc", StringComparison.OrdinalIgnoreCase); byte[] key = NormalizeKey(keyText); var processor = new DesFileProcessor(key, useCbc); try { if (action == "encrypt") { processor.EncryptFile(sourcePath, destPath); Console.WriteLine($"加密完成:{sourcePath} -> {destPath}"); } else if (action == "decrypt") { processor.DecryptFile(sourcePath, destPath); Console.WriteLine($"解密完成:{sourcePath} -> {destPath}"); } else { Console.WriteLine($"未知操作:{action}"); PrintUsage(); } } catch (Exception ex) { Console.WriteLine($"操作失败:{ex.Message}"); Environment.ExitCode = 1; } } private static void PrintUsage() { Console.WriteLine("用法:"); Console.WriteLine(" DesFileEncryptor encrypt <源文件> <目标文件> <密钥> [cbc|ecb]"); Console.WriteLine(" DesFileEncryptor decrypt <源文件> <目标文件> <密钥> [cbc|ecb]"); Console.WriteLine("示例:"); Console.WriteLine(" DesFileEncryptor encrypt a.txt a.bin mykey888 cbc"); Console.WriteLine(" DesFileEncryptor decrypt a.bin b.txt mykey888 cbc"); } /// <summary> /// 把任意字符串密钥规范化成8字节。 /// 超过8字节截断,不足8字节补0。 /// </summary> private static byte[] NormalizeKey(string keyText) { byte[] raw = Encoding.UTF8.GetBytes(keyText ?? ""); byte[] result = new byte[8]; int copyLen = Math.Min(raw.Length, 8); Array.Copy(raw, result, copyLen); return result; } /// <summary> /// 使用DES标准测试向量验证算法正确性。 /// 密钥: 13 34 57 79 9B BC DF F1 /// 明文: 01 23 45 67 89 AB CD EF /// 密文: 85 E8 13 54 0A B4 05 16 /// </summary> private static void RunSelfTest() { byte[] key = { 0x13, 0x34, 0x57, 0x79, 0x9B, 0xBC, 0xDF, 0xF1 }; byte[] plain = { 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF }; byte[] expectedCipher = { 0x85, 0xE8, 0x13, 0x54, 0x0A, 0xB4, 0x05, 0x16 }; var des = new DesCore(key); byte[] actualCipher = des.EncryptBlock(plain); bool cipherOk = actualCipher.SequenceEqual(expectedCipher); byte[] actualPlain = des.DecryptBlock(actualCipher); bool plainOk = actualPlain.SequenceEqual(plain); if (cipherOk && plainOk) { Console.WriteLine("自检通过:标准测试向量加密、解密结果均正确。"); } else { Console.WriteLine("自检失败:请检查置换表或S盒是否抄写正确。"); } } } }

自检这一步非常关键。我每次改完代码,第一件事就是跑RunSelfTest,确保核心算法没有回归。这个测试向量来自DES标准资料,密钥、明文、密文都是公开固定的,如果加密后输出的字节和期望值完全一致,说明核心算法基本正确。当然这只是“正确性”的第一步,后面还要做文件往返测试。

4. 实测验证:测试向量、文件往返与性能表现

4.1 标准测试向量验证到底验证了什么

RunSelfTest里的那组数据,相当于DES世界的“hello world”。它验证的是:给定一个已知密钥和一个已知明文,经过16轮迭代后,输出的密文和标准答案完全一致。如果某个S盒抄错,或者子密钥生成的顺序错了,最终密文几乎不可能碰巧一致,因为每一轮的扩散效应都会把差异放大。

我也试过只差一个S盒数字的情况,结果就是密文完全不对,而且解密回来也是乱码。所以建议你复制源码后先别急着加密文件,先跑一遍自检。

4.2 文件加密往返测试

核心算法没问题之后,我建了一个测试目录,放入一个约几十KB的文本文件,里面是几段业务日志。执行:

DesFileEncryptor encrypt C:\tmp\test.log C:\tmp\test.enc mykey888 cbc DesFileEncryptor decrypt C:\tmp\test.enc C:\tmp\test.out mykey888 cbc

然后逐字节比对test.log和test.out,结果完全一致。我又故意测试了文件大小正好是8字节和16字节的情况,这是最容易触发PKCS7边界问题的场景。比如一个8字节文件加密后会变成24字节:8字节IV头 + 8字节原数据密文 + 8字节填充块密文。解密后又恢复成8字节,说明填充去掉逻辑正确。

我再做了一次“错误密钥解密”测试:用mykey888加密,用mykey999解密。程序在最后一个块检测到填充字节非法,抛出异常。这个行为符合预期,证明填充校验起到了“数据完整性初步检查”的作用。

4.3 不同文件大小的性能参考

手写DES和系统内置AES的差距是数量级的,这是正常现象。下面是我在普通笔记本上的一组粗略数据,使用CBC模式,Debug模式运行,仅供参考:

文件大小加密耗时解密耗时
1 KB约 2 ms约 2 ms
256 KB约 30 ms约 30 ms
1 MB约 110 ms约 110 ms
10 MB约 1.1 s约 1.1 s

如果换成Release模式,速度会快不少。但如果你拿它和系统类库的AES-GCM比,还是差很多。

性能瓶颈主要在两处:一是每8字节都要做64次表查找和位运算,二是文件层每块都调用方法,方法调用本身有开销。优化方向可以考虑:用更大缓冲区批量读文件、减少Clone次数、把S盒合并成一个大查找表(DES的经典查表优化)。

不过说实话,这个项目的核心目标不是跑分。如果你真的需要在C#里对文件做高强度加密,直接用系统自带算法是正确的选择。手写DES更像是一次“解剖课”,性能不是它的重点。

4.4 ECB与CBC的实际差异

我在测试时顺手做了一件有意思的事:用ECB模式加密一张内容有大片相同颜色的简单位图,结果密文文件里能隐约看出原图的轮廓;而同样的图用CBC模式加密,密文看起来完全随机。这个对比直观地说明了ECB模式的弱点。

如果你只是拿这个代码做作业,ECB已经够用了。但如果你想让它“能用在真实场景”,务必选CBC模式。代码里默认就是CBC,这个默认值是有意的。

5. 常见问题与排查技巧实录

5.1 解密后末尾出现乱码

这个问题的原因几乎都是填充处理没做对。常见情况是文件大小正好是8的倍数时,没有额外追加填充块,解密时把原数据最后一个字节误当成填充长度,多删了数据;或者反过来,填充块没去掉,解密文件末尾多出8个0x08。

我的解决方法是先在代码层面把PKCS7的三种情况分别测试:文件长度小于8字节、等于8字节、大于8字节但不是8倍数。每种情况都写一个明确断言,确保加解密往返一致。

5.2 密钥长度引发异常

DesCore构造函数严格要求8字节密钥。如果你直接用字符串“abc”转字节数组,长度是3,就会抛异常。NormalizeKey方法就是为此设计的:不足补零,超出截断。

这里有一个容易被忽略的点:NormalizeKey补零后,密钥的实际有效位可能非常弱。如果你只是测试算法,无所谓;但如果你真的在维护老系统,千万别用这种规则产生密钥。

5.3 加密后文件大小比原文件大很多

这是分组密码的正常现象。原因有两个:一是PKCS7填充会把文件大小凑成8的倍数,如果原文件本身是8倍数,还会额外多出一个填充块;二是CBC模式的IV头占了8字节。所以文件加密后增长幅度最多是8字节IV + 8字节填充,也就是最多多16字节。如果你的文件加密后比预期大了几十字节,那可能是你的模式模式选择或文件头处理有问题。

5.4 自检失败:加密结果和标准答案不一致

遇到这种情况,别慌,按顺序排查:

  • 先检查置换表是否抄错。IP、IP_INV、E、P、PC1、PC2,任何一个值错一位,结果都会对不上。
  • 再检查S盒。尤其是S盒的行列顺序,不要看错。
  • 然后检查ProcessBlock里最后一轮的输出拼接顺序,应该输出R在前、L在后。
  • 最后检查子密钥顺序。加密用顺序,解密用逆序,正好反了也会导致结果错误。

我实际调这个项目时,就是先跑自检,发现密文不对,然后用断点逐步跟踪,在第一轮迭代后对比标准资料里的中间值,一下子就锁定了问题。

5.5 DES本身的安全边界:能用来做什么

这个项目做完了,但我必须把话说清楚:DES的56位有效密钥在今天已经不够安全。专门的攻击设备可以在很短时间内完成暴力破解。所以这篇文章的代码适合教学、适合理解原理、适合兼容一些老系统,但绝对不适合作为新系统的数据保护方案。

如果你在维护老系统,确实需要处理DES加密的数据,建议在代码里明确标记这块逻辑已经过期,后续尽量通过兼容层平滑迁移到AES等更安全的算法。如果你是在做新项目,直接选择系统库里的AES-GCM或AES-CBC,密钥长度至少128位以上。

写这个项目最大的收获,其实不是把置换表背下来,而是在调试S盒的时候,我第一次真切感受到“扩散”和“混淆”不只是两个概念名词:一个看起来完全随机的表,是如何在16轮迭代里把明文的每个比特搅到整个密文中去的。如果你也在做类似的算法实现作业,我的建议是先别急着敲代码,拿张纸、拿支笔,把某一轮迭代里的数据每一比特走向画一遍,比任何调试器都有用。最后再提醒一句:代码能跑不代表它能守门,真正重要的文件,该交给更年轻的算法去保护。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询