1. 项目概述:为什么还在用DES?
看到这个标题,很多朋友可能会一愣:都什么年代了,还在讲DES加密?AES不是更安全、更主流吗?这话没错,但现实情况是,在很多遗留系统、特定行业协议(比如一些金融终端的老接口)或者对性能有极致要求的嵌入式场景里,DES(Data Encryption Standard)及其变体3DES依然顽强地活着。作为一名干了十多年的老码农,我处理过太多需要与这些“老古董”打交道的项目了。新项目当然首选AES,但当你需要维护、对接或者理解一段历史代码时,掌握DES的实现原理和C#下的实操细节,就从一个“可选项”变成了“必选项”。
这个项目的核心,就是带你用C#从头实现一遍DES的加密和解密。不止是调用System.Security.Cryptography命名空间下现成的类库(那个太简单了),而是深入到算法内部,理解其核心的Feistel网络结构、密钥编排以及S盒置换等关键步骤。我会附上完整的、注释详尽的源码,你可以直接拿去用,更重要的是,你能明白每一行代码在干什么,遇到异常(比如经典的“密钥长度错误”)时,知道问题出在哪个环节,而不是对着报错信息干瞪眼。
2. DES算法核心原理快速回顾
在动手写代码之前,我们必须先统一“语言”,理解DES到底是怎么工作的。如果你已经熟悉,可以快速浏览;如果是新手,这里是你避坑的基础。
DES是一种对称分组加密算法,核心参数就几个:64位密钥(实际使用56位+8位奇偶校验)、64位明文/密文分组、16轮迭代加密。它的核心结构是Feistel网络,这种结构的精妙之处在于,加密和解密过程可以使用几乎相同的逻辑,只是子密钥的使用顺序相反,这大大简化了实现。
2.1 核心流程拆解
一次完整的DES加密,可以粗略分为以下几个大步骤:
- 初始置换(IP):对输入的64位明文按固定表进行位置重排。这是个简单的位操作,不提供安全性,据说是为了兼容老式硬件。
- 16轮Feistel迭代:这是算法的核心。每一轮的操作都类似:
- 将64位数据分成左右两半,各32位(L0, R0)。
- 右半部分R(i-1)经过一个轮函数F处理后,与左半部分L(i-1)进行异或(XOR),得到新的右半部分R(i)。
- 新的左半部分L(i)直接等于上一轮的右半部分R(i-1)。
- 公式表达:
L(i) = R(i-1);R(i) = L(i-1) XOR F(R(i-1), K(i))。其中K(i)是第i轮的子密钥。
- 末置换(IP^-1):16轮结束后,将左右半部分交换(这是Feistel网络的最后一步),再经过一个与初始置换互逆的置换,得到最终的64位密文。
解密过程完全一样,只是16轮子密钥K(1)~K(16)的使用顺序倒过来,变成K(16)~K(1)。
2.2 轮函数F与S盒:安全性的灵魂
轮函数F(R, K)是DES安全性的关键,它做了以下几件事:
- 扩展置换(E):将32位的右半部分R扩展为48位。简单说就是某些位被重复使用了。
- 与子密钥异或:将扩展后的48位结果与48位的本轮子密钥
K(i)进行按位异或。 - S盒替换(核心中的核心):将异或后的48位数据分成8组,每组6位,送入8个不同的S盒(Substitution-box)中。每个S盒是一个固定的4行16列的查找表,它接收6位输入,输出4位。这一步是DES唯一的非线性变换,是整个算法混淆性的来源。如果DES有弱点,攻击者主要就是盯着S盒琢磨。
- P盒置换:将8个S盒输出的32位结果,再经过一个固定的位置置换(P盒),打乱顺序,增加扩散性。
2.3 密钥编排:从1个密钥到16个子密钥
原始的64位密钥(含8位校验位)经过一个置换选择(PC-1)变成56位有效密钥。这56位被分成左右各28位(C0, D0)。在每一轮,左右两部分分别进行循环左移(移位数根据轮数固定),然后合并,再经过一个置换选择(PC-2)压缩成48位的本轮子密钥K(i)。
注意:这里有一个初学者极易混淆的点。我们常说的“DES密钥是8字节(64位)”,但实际参与加密运算的只有56位。那8位是奇偶校验位,通常库函数会忽略它们,或者要求你提供的就是有效的56位密钥材料。在C#的
DESCryptoServiceProvider中,如果你提供的密钥长度不对,就会抛出CryptographicException。
理解了这些,我们就有了画图纸。接下来,就是用C#把这些步骤“翻译”成代码。
3. C#实现DES的核心数据结构与工具方法
我们不直接使用.NET内置的DESCryptoServiceProvider,而是自己构建这些过程。这能让你对位操作、数据转换有更深的理解。
首先,我们需要一些基础工具来处理位(bit)级别的操作。C#没有直接的“位数组”类型,但我们可以用ulong(64位无符号整数)和uint(32位无符号整数)来模拟,并通过位运算(&,|,<<,>>,^)来提取、设置和置换位。
3.1 定义置换表与S盒
所有DES的置换表和S盒都是公开的、固定的。我们需要将它们定义为静态的只读数组。这是整个算法的“宪法”。
public static class DesConstants { // 初始置换IP表 (64位 -> 64位) public static readonly int[] IP = { 58, 50, 42, 34, 26, 18, 10, 2, 60, 52, 44, 36, 28, 20, 12, 4, // ... 省略中间部分,实际代码需补全64个值 15, 7, 62, 54, 46, 38, 30, 22 }; // 逆初始置换IP^-1表 public static readonly int[] IPInverse = { /* 64个值 */ }; // 扩展置换E表 (32位 -> 48位) public static readonly int[] E = { /* 48个值 */ }; // P盒置换表 (32位 -> 32位) public static readonly int[] P = { /* 32个值 */ }; // PC-1置换表 (64位密钥 -> 56位有效密钥) public static readonly int[] PC1 = { /* 56个值 */ }; // PC-2置换表 (56位 -> 48位子密钥) public static readonly int[] PC2 = { /* 48个值 */ }; // 每轮循环左移的位数表 public static readonly int[] KeyShifts = { 1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1 }; // 8个S盒,每个是4x16的二维数组 public static readonly int[][,] SBoxes = new int[8][,] { new int[4,16] { /* S1 */ }, new int[4,16] { /* S2 */ }, // ... S3 到 S8 }; }实操心得:这些表又长又容易抄错。我的建议是,从一个可靠的学术或开源实现中直接复制粘贴数组定义。自己手动输入一遍是很好的学习方式,但用于生产环境,务必进行完整的加解密测试来验证表的正确性。一个数字错了,整个加解密结果就全乱了。
3.2 位操作工具类
我们需要一个类来执行“置换”操作:根据一个置换表,将输入数据的位重新排列。
public static class BitHelper { /// <summary> /// 通用置换函数 /// </summary> /// <param name="input">输入数据块</param> /// <param name="table">置换表</param> /// <param name="inputBits">输入数据的位数(如64,32)</param> /// <returns>置换后的结果</returns> public static ulong Permute(ulong input, int[] table, int inputBits) { ulong result = 0; for (int i = 0; i < table.Length; i++) { // 置换表table[i]的值表示:结果数据的第i位,来自输入数据的第table[i]位。 // 注意:很多资料的表是从1开始计数的,而我们的位操作是从0开始(最低位为0)。 // 因此,如果表是从1开始的,需要`table[i] - 1`。 int srcPos = table[i] - 1; // 假设我们的表定义是从1开始的 // 检查输入数据的第srcPos位是否为1 if ((input & (1UL << (inputBits - 1 - srcPos))) != 0) { // 将结果数据的对应位置1 // 结果数据的第i位,对应 `table.Length - 1 - i` 还是 `i`,取决于你的位序约定。 // 这里采用常见约定:最高位(最左边)为第0位。 result |= (1UL << (table.Length - 1 - i)); } } return result; } /// <summary> /// 将8字节数组转换为一个64位无符号整数(ulong) /// </summary> public static ulong BytesToUInt64(byte[] bytes, int startIndex = 0) { // 注意字节序(Endianness)!DES标准通常采用大端序(Big-Endian)。 // 即数组的第一个字节(索引0)是最高有效字节。 ulong result = 0; for (int i = 0; i < 8; i++) { result = (result << 8) | bytes[startIndex + i]; } return result; } /// <summary> /// 将一个64位无符号整数(ulong)转换为8字节数组(大端序) /// </summary> public static byte[] UInt64ToBytes(ulong value) { byte[] bytes = new byte[8]; for (int i = 0; i < 8; i++) { bytes[i] = (byte)((value >> (56 - i * 8)) & 0xFF); } return bytes; } }注意事项:位序(Bit Order)和字节序(Byte Order)是DES实现中最容易出错的地方!不同的资料、不同的硬件平台、不同的编程语言库,可能采用不同的约定。上述工具方法采用了一种常见的约定:将数据块视为一个从最高有效位(MSB)到最低有效位(LSB)的序列。置换表中的位置索引也是基于这个约定。你必须确保你的置换表定义、位提取和设置逻辑在整个项目中保持一致。我强烈建议在实现后,用标准的测试向量(如NIST提供的)进行验证。
4. 密钥编排系统的实现
密钥编排是独立的模块,它接收原始密钥,生成16轮子密钥。这个过程在加密和解密前只做一次。
public class DesKeyScheduler { private readonly ulong[] _roundKeys = new ulong[16]; // 存储16轮48位子密钥(这里用ulong低48位存储) public DesKeyScheduler(byte[] key) { if (key == null) throw new ArgumentNullException(nameof(key)); if (key.Length != 8) throw new ArgumentException("DES key must be exactly 8 bytes (64 bits).", nameof(key)); ulong key64 = BitHelper.BytesToUInt64(key); // 1. 通过PC-1置换,得到56位有效密钥 ulong key56 = BitHelper.Permute(key64, DesConstants.PC1, 64); // 拆分56位为左右各28位 uint c = (uint)((key56 >> 28) & 0x0FFFFFFF); // 高28位 uint d = (uint)(key56 & 0x0FFFFFFF); // 低28位 for (int round = 0; round < 16; round++) { // 2. 循环左移 int shift = DesConstants.KeyShifts[round]; c = RotateLeft28(c, shift); d = RotateLeft28(d, shift); // 3. 合并后通过PC-2置换生成48位子密钥 ulong cd = ((ulong)c << 28) | d; ulong roundKey = BitHelper.Permute(cd, DesConstants.PC2, 56); _roundKeys[round] = roundKey; } } public ulong GetRoundKey(int roundIndex) // roundIndex: 0~15 { if (roundIndex < 0 || roundIndex >= 16) throw new ArgumentOutOfRangeException(nameof(roundIndex)); return _roundKeys[roundIndex]; } // 获取用于解密的子密钥(逆序) public ulong GetRoundKeyForDecrypt(int roundIndex) // roundIndex: 0~15 { return GetRoundKey(15 - roundIndex); } private static uint RotateLeft28(uint value, int shift) { // 28位循环左移 return ((value << shift) & 0x0FFFFFFF) | (value >> (28 - shift)); } }关键点解析:
RotateLeft28函数中的掩码0x0FFFFFFF至关重要。因为uint是32位,我们对28位数据进行左移时,高位可能会移出28位范围,同时低位移上来的部分也可能超出。这个掩码确保了只保留最低的28位,模拟了28位寄存器的行为。这是实现正确的密钥编排的细节之一。
5. 轮函数F与单轮加密的实现
这是DES算法最核心的部分,我们把它单独实现。
public static class DesRoundFunction { /// <summary> /// DES轮函数 F(R, K) /// </summary> /// <param name="r">32位右半部分</param> /// <param name="roundKey">48位本轮子密钥</param> /// <returns>32位输出</returns> public static uint Compute(uint r, ulong roundKey) { // 1. 扩展置换 E: 32位 -> 48位 ulong expanded = BitHelper.Permute(r, DesConstants.E, 32); // 2. 与子密钥异或 ulong xored = expanded ^ roundKey; // 结果仍是48位 // 3. S盒替换: 48位 -> 32位 uint substituted = SBoxSubstitution(xored); // 4. P盒置换 uint output = (uint)BitHelper.Permute(substituted, DesConstants.P, 32); return output; } private static uint SBoxSubstitution(ulong input48) { uint output32 = 0; // 将48位输入分成8组,每组6位 for (int i = 0; i < 8; i++) { // 提取第i个6位组 (从最高位开始算) int shift = 42 - i * 6; ulong group6 = (input48 >> shift) & 0x3F; // 0x3F = 二进制 0011 1111 // 计算S盒的行和列索引 // 6位组: b1 b2 b3 b4 b5 b6 // 行号 = (b1 << 1) | b6 (即头尾两位组成2位二进制数,0-3) // 列号 = b2 b3 b4 b5 (中间四位组成4位二进制数,0-15) int row = (int)(((group6 >> 5) & 0x01) << 1) | (int)(group6 & 0x01); int col = (int)((group6 >> 1) & 0x0F); // 查询S盒表 int sBoxValue = DesConstants.SBoxes[i][row, col]; // 将4位输出拼接到最终结果中 output32 <<= 4; // 为新的4位腾出空间 output32 |= (uint)(sBoxValue & 0x0F); } return output32; } }实操心得:S盒替换是位操作最密集的地方,也是最考验对算法理解的地方。
group6的提取和row、col的计算必须严格按照DES标准定义。这里我采用的索引计算方法是经典的一种。务必用已知的测试数据验证你的S盒输出是否正确。一个快速验证方法是:找一个在线DES计算工具,输入一组简单的中间数据(比如R=0x00000000,K=0x000000000000),看你的Compute函数输出是否与标准结果一致。
6. 完整的DES加密/解密流程整合
现在,我们把所有部件组装起来,实现完整的ECB(电子密码本)模式加密解密。ECB模式是最简单的,就是对每个64位分组独立进行加密。
public class DesCipher { public static byte[] Encrypt(byte[] plaintext, byte[] key) { ValidateInput(plaintext, key); DesKeyScheduler scheduler = new DesKeyScheduler(key); // 处理填充:DES是64位分组密码,明文长度必须是8字节的倍数。 // 这里使用PKCS#7填充(也叫PKCS#5填充)。 byte[] paddedData = ApplyPadding(plaintext, 8); byte[] ciphertext = new byte[paddedData.Length]; // 分块加密 for (int i = 0; i < paddedData.Length; i += 8) { ulong block = BitHelper.BytesToUInt64(paddedData, i); ulong encryptedBlock = ProcessBlock(block, scheduler, isEncrypt: true); byte[] encryptedBytes = BitHelper.UInt64ToBytes(encryptedBlock); Array.Copy(encryptedBytes, 0, ciphertext, i, 8); } return ciphertext; } public static byte[] Decrypt(byte[] ciphertext, byte[] key) { ValidateInput(ciphertext, key); if (ciphertext.Length % 8 != 0) throw new ArgumentException("Ciphertext length must be a multiple of 8 bytes for DES.", nameof(ciphertext)); DesKeyScheduler scheduler = new DesKeyScheduler(key); byte[] decryptedPaddedData = new byte[ciphertext.Length]; for (int i = 0; i < ciphertext.Length; i += 8) { ulong block = BitHelper.BytesToUInt64(ciphertext, i); ulong decryptedBlock = ProcessBlock(block, scheduler, isEncrypt: false); byte[] decryptedBytes = BitHelper.UInt64ToBytes(decryptedBlock); Array.Copy(decryptedBytes, 0, decryptedPaddedData, i, 8); } // 去除填充 return RemovePadding(decryptedPaddedData); } private static ulong ProcessBlock(ulong block, DesKeyScheduler scheduler, bool isEncrypt) { // 1. 初始置换IP ulong permuted = BitHelper.Permute(block, DesConstants.IP, 64); // 拆分成左右32位 uint left = (uint)(permuted >> 32); uint right = (uint)(permuted & 0xFFFFFFFF); // 2. 16轮Feistel迭代 for (int round = 0; round < 16; round++) { ulong roundKey = isEncrypt ? scheduler.GetRoundKey(round) : scheduler.GetRoundKeyForDecrypt(round); uint fResult = DesRoundFunction.Compute(right, roundKey); uint newRight = left ^ fResult; // 为下一轮准备 left = right; right = newRight; } // 3. 最后交换左右并合并 (Feistel网络最后一轮后需要交换) ulong combined = ((ulong)right << 32) | left; // 4. 末置换 IP^-1 ulong result = BitHelper.Permute(combined, DesConstants.IPInverse, 64); return result; } private static void ValidateInput(byte[] data, byte[] key) { if (data == null) throw new ArgumentNullException(nameof(data)); if (key == null) throw new ArgumentNullException(nameof(key)); if (key.Length != 8) throw new ArgumentException("Key must be 8 bytes (64 bits) for DES.", nameof(key)); } // PKCS#7 填充与去填充 private static byte[] ApplyPadding(byte[] data, int blockSize) { int paddingLength = blockSize - (data.Length % blockSize); if (paddingLength == 0) paddingLength = blockSize; // 如果正好是整数倍,补一个完整块 byte[] padded = new byte[data.Length + paddingLength]; Array.Copy(data, 0, padded, 0, data.Length); for (int i = data.Length; i < padded.Length; i++) { padded[i] = (byte)paddingLength; } return padded; } private static byte[] RemovePadding(byte[] paddedData) { if (paddedData.Length == 0) return paddedData; byte paddingLength = paddedData[paddedData.Length - 1]; if (paddingLength <= 0 || paddingLength > 8) // DES块大小是8 throw new CryptographicException("Invalid padding."); // 简单验证:填充字节的值都应该等于paddingLength for (int i = paddedData.Length - paddingLength; i < paddedData.Length; i++) { if (paddedData[i] != paddingLength) throw new CryptographicException("Invalid padding."); } byte[] data = new byte[paddedData.Length - paddingLength]; Array.Copy(paddedData, 0, data, 0, data.Length); return data; } }注意事项:我们这里实现了最基础的ECB模式。重要警告:ECB模式是不安全的!对于重复的明文块,ECB会产生重复的密文块,这会泄露数据模式。在实际项目中,绝对不要用ECB模式加密有意义的数据。应该使用CBC、CFB等更安全的模式。为了教学清晰,这里展示了ECB。如果你要实用,必须在ECB的基础上实现其他模式,这涉及到初始化向量(IV)和块之间的异或运算。
7. 测试、验证与常见问题排查
代码写完了,怎么知道它对不对?我们必须用标准测试向量来验证。
7.1 使用标准测试向量验证
NIST等机构提供了标准的DES测试向量。这里我们使用一组最经典的:
public static void TestDesImplementation() { // 测试向量:明文、密钥、密文 (十六进制表示) string plaintextHex = "0123456789ABCDEF"; string keyHex = "133457799BBCDFF1"; string expectedCipherHex = "85E813540F0AB405"; // 这是标准结果 byte[] plaintext = HexStringToByteArray(plaintextHex); byte[] key = HexStringToByteArray(keyHex); // 加密测试 byte[] ciphertext = DesCipher.Encrypt(plaintext, key); string cipherHex = BitConverter.ToString(ciphertext).Replace("-", ""); Console.WriteLine($"加密结果: {cipherHex}"); Console.WriteLine($"期望结果: {expectedCipherHex}"); Console.WriteLine($"加密测试: {cipherHex.Equals(expectedCipherHex, StringComparison.OrdinalIgnoreCase)}"); // 解密测试 byte[] decrypted = DesCipher.Decrypt(ciphertext, key); string decryptedHex = BitConverter.ToString(decrypted).Replace("-", ""); Console.WriteLine($"解密结果: {decryptedHex}"); Console.WriteLine($"原始明文: {plaintextHex}"); Console.WriteLine($"解密测试: {decryptedHex.Equals(plaintextHex, StringComparison.OrdinalIgnoreCase)}"); } private static byte[] HexStringToByteArray(string hex) { // 简单的十六进制字符串转字节数组,假设字符串长度是偶数且无空格 byte[] bytes = new byte[hex.Length / 2]; for (int i = 0; i < bytes.Length; i++) { bytes[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16); } return bytes; }运行这个测试,如果输出全是True,那么恭喜你,你的DES核心算法实现基本正确。
7.2 常见问题与排查技巧实录
在实际编码和调试中,你几乎一定会遇到下面这些问题。我把我的排查经验整理成表,你可以对照着看。
| 问题现象 | 可能原因 | 排查思路与解决方案 |
|---|---|---|
| 加密结果与标准测试向量完全对不上 | 1. 置换表(IP, IP^-1, E, P, PC1, PC2)数据错误。 2. S盒数据错误。 3.位序或字节序处理错误(最常见)。 | 1.优先检查位序:确认你的Permute函数和表定义是基于同一位序约定(MSB first还是LSB first)。用一个简单的输入(如0x8000000000000000,只有最高位是1)测试初始置换IP,看输出位的移动是否符合预期。2. 逐轮打印中间结果。实现一个“调试模式”,在每一轮Feistel迭代后,打印出L, R, roundKey的十六进制值,与已知的正确中间值对比。网上可以找到一些DES的中间步骤测试数据。 3. 使用一个绝对可靠的第三方实现(如OpenSSL命令行)作为参照,用相同输入逐步对比。 |
| 加密结果最后几个字节不对,但前面似乎是对的 | 填充(Padding)逻辑错误。 | 1. 测试不填充的情况(输入正好是8字节倍数)。如果对了,问题就在填充/去填充函数。 2. 检查 ApplyPadding和RemovePadding函数。PKCS#7填充是在末尾添加n个值为n的字节。例如,需要补3字节,就添加0x03 0x03 0x03。3. 去填充时,要验证最后一个字节的值是否在有效范围(1-8),并且末尾的n个字节是否都等于n。 |
| 解密失败,报“Invalid padding”异常 | 1. 密钥错误,导致解密出的数据末尾不是有效的填充字节。 2. 密文在传输或存储过程中被损坏。 3. 加密和解密使用的模式不匹配(比如加密用了CBC,解密用了ECB)。 | 1. 确认加解密使用的密钥完全相同。 2. 对于网络传输或文件存储,确保密文被完整、正确地读取,没有发生编码转换(如Base64解码错误)。 3.如果是ECB模式,尝试不解密,直接打印解密函数中末置换(IP^-1)后的 combined值。如果这个值看起来是乱码,说明加解密过程本身可能有问题。如果这个值看起来有规律(比如末尾是0x01或0x02 0x02等),可能是密钥错误导致数据部分正确但填充验证失败。可以尝试暂时注释掉填充验证,看解密出的原始数据是什么。 |
与.NET内置的DESCryptoServiceProvider结果不一致 | 1. 工作模式不同(ECB vs CBC)。 2. 填充模式不同(PKCS#7 vs Zeros vs None)。 3. 初始化向量(IV)的影响(CBC模式)。 4. 密钥处理方式不同(.NET可能对弱密钥有检查或特殊处理)。 | 1. 将你的实现和.NET库都设置为ECB模式、PKCS7填充、无IV进行对比。 2. 使用相同的明文和密钥(确保是8字节)。 3. 分别输出两者加密后的原始字节数组进行比对,不要转成Base64或Hex后比较,避免编码问题。 4. .NET库默认可能使用CBC模式,你需要显式设置 Mode = CipherMode.ECB。 |
| 性能非常慢 | 算法本身是位操作密集型,纯软件实现本来就慢。 | 1. 这是正常的。DES的软件实现效率不高,这也是它被AES取代的原因之一。 2. 对于生产环境,如果必须使用DES,强烈建议使用硬件加速(如果CPU支持)或使用经过高度优化的库(如.NET内置的)。 3. 我们的实现是教学目的,旨在清晰,而非高效。 |
7.3 从ECB扩展到CBC模式
如前所述,ECB不安全。我们快速看一下如何修改我们的代码来实现CBC(密码分组链接)模式,这是最常用的模式之一。
CBC模式的核心是引入一个初始化向量(IV),并且每一个明文块在加密前,先与前一个密文块(第一个块与IV)进行异或。
public static byte[] EncryptCbc(byte[] plaintext, byte[] key, byte[] iv) { // 参数校验略... if (iv.Length != 8) throw new ArgumentException("IV must be 8 bytes for DES CBC.", nameof(iv)); byte[] paddedData = ApplyPadding(plaintext, 8); byte[] ciphertext = new byte[paddedData.Length]; DesKeyScheduler scheduler = new DesKeyScheduler(key); ulong previousBlock = BitHelper.BytesToUInt64(iv); // 第一个块的前置块是IV for (int i = 0; i < paddedData.Length; i += 8) { ulong plainBlock = BitHelper.BytesToUInt64(paddedData, i); // CBC核心:明文块与前一个密文块(或IV)异或 ulong blockToEncrypt = plainBlock ^ previousBlock; ulong encryptedBlock = ProcessBlock(blockToEncrypt, scheduler, isEncrypt: true); byte[] encryptedBytes = BitHelper.UInt64ToBytes(encryptedBlock); Array.Copy(encryptedBytes, 0, ciphertext, i, 8); previousBlock = encryptedBlock; // 更新“前一个密文块” } return ciphertext; } public static byte[] DecryptCbc(byte[] ciphertext, byte[] key, byte[] iv) { // 参数校验略... if (ciphertext.Length % 8 != 0) throw new ArgumentException(...); if (iv.Length != 8) throw new ArgumentException(...); byte[] decryptedPaddedData = new byte[ciphertext.Length]; DesKeyScheduler scheduler = new DesKeyScheduler(key); ulong previousCipherBlock = BitHelper.BytesToUInt64(iv); for (int i = 0; i < ciphertext.Length; i += 8) { ulong currentCipherBlock = BitHelper.BytesToUInt64(ciphertext, i); // 先解密当前密文块 ulong decryptedBlock = ProcessBlock(currentCipherBlock, scheduler, isEncrypt: false); // CBC核心:解密后的块再与前一个密文块(或IV)异或,得到明文 ulong plainBlock = decryptedBlock ^ previousCipherBlock; byte[] plainBytes = BitHelper.UInt64ToBytes(plainBlock); Array.Copy(plainBytes, 0, decryptedPaddedData, i, 8); previousCipherBlock = currentCipherBlock; // 更新“前一个密文块” } return RemovePadding(decryptedPaddedData); }关键点解析:注意CBC解密时,是“先解密,再异或”,并且异或的对象是“前一个密文块”,而不是“前一个解密后的块”。这是CBC模式的一个特点,也使得它可以并行加密(但无法并行解密)。IV不需要保密,但必须是随机的且不可预测,通常随密文一起传输。
8. 总结与最终建议
走完这一趟,你应该对DES的内部构造有了肌肉记忆级别的理解。从置换表、S盒、密钥编排,到Feistel网络和完整的加解密流程,我们亲手用C#搭建了这个经典的密码引擎。虽然DES因其56位密钥长度已不再安全(暴力破解在当今硬件下已可行),但3DES(使用两个或三个密钥进行三次DES加密)在一些场景下仍有应用,而理解DES是理解3DES和许多其他分组密码的基础。
最后再分享几个关键建议:
- 不要在新项目中使用DES/ECB:如果安全性重要,请使用AES(
AesCryptoServiceProvider)。如果因为兼容性必须用DES,至少使用CBC模式并确保IV随机。 - 处理密钥要小心:密钥管理是加密系统中最脆弱的一环。不要硬编码在代码里,考虑使用安全的密钥存储方案。
- 验证、验证、再验证:密码学实现容不得半点马虎。务必使用多个官方测试向量进行验证,包括边界情况(全0数据、全1数据等)。
- 理解比调用更重要:虽然99%的情况下你都应该使用经过严格审计的加密库(如.NET Framework自带的
System.Security.Cryptography),但通过这次实现,当下次遇到诸如“填充错误”、“密钥长度无效”这样的异常时,你就能立刻想到问题可能出在哪个环节,而不是盲目搜索。
这份完整的源码和详细的步骤解析,希望能成为你理解对称加密的一个扎实起点。密码学的世界很深,但每一步拆解开来,都是严谨而优美的逻辑。