C++实现DES加密算法:从Feistel结构到分组密码实践

📅 2026/7/21 19:06:25
C++实现DES加密算法:从Feistel结构到分组密码实践
1. 项目概述从理论到实践的DES加密之旅最近在整理一些旧项目翻到了当年在学校信息安全课上做的DES加密算法实现。说实话现在AES已经是主流但在理解对称加密的底层逻辑上DES依然是一个绝佳的“教学标本”。它结构清晰包含了分组加密几乎所有的核心概念置换、迭代、S盒、密钥调度。用C亲手实现一遍远比只看书或者调用现成的库要来得深刻。这个项目适合所有对密码学感兴趣或者想深入理解C在底层数据处理比如位操作、数组管理上如何发挥威力的朋友。无论你是正在学习《密码学》课程的学生还是希望夯实C基本功、挑战一下复杂逻辑实现的开发者跟着这个思路走一遍收获的绝不仅仅是一个能加密解密的程序更是一套处理复杂问题的思维方法和扎实的编程功底。2. DES算法核心原理快速解析在动手写代码之前我们必须先搞清楚DES到底在干什么。DES是一种分组密码每次处理64位8字节的明文数据块输出64位的密文使用的密钥长度是56位外加8位奇偶校验位通常我们说64位密钥。它的核心思想是“混淆”和“扩散”通过多轮复杂的置换和替代操作让明文和密钥之间的关系变得极其复杂。2.1 核心流程Feistel网络结构DES采用的是经典的Feistel网络结构。这是它最巧妙的设计之一也是我们实现时需要牢牢把握的框架。Feistel结构的核心优势在于加密和解密过程可以使用几乎相同的逻辑只是子密钥的使用顺序相反这极大地简化了我们的代码设计。具体来说对于每一轮加密将64位的输入数据分成左右两半各32位记为L和R。本轮的输出左半部分L’直接等于上一轮的右半部分R。本轮的输出右半部分R’等于上一轮的左半部分L与一个轮函数F(R, K)的结果进行异或XOR。这里的K是本轮的子密钥。用公式表示就是L’ RR’ L XOR F(R, K)看到这里你可能会想解密时怎么办神奇之处就在于由于XOR运算的特性A XOR B XOR B A解密过程只需要将密文作为输入并逆序使用子密钥K套用完全相同的Feistel结构即可还原出明文。这意味着我们只需要实现一个F函数和一个密钥调度算法加密和解密的主循环结构可以复用。2.2 轮函数F算法的“心脏”轮函数F是DES安全性的关键它接受32位的右半部分R和48位的子密钥K输出一个32位的结果。其内部步骤是标准且固定的扩展置换E-box将32位的R扩展为48位。这不是简单填充而是通过重复某些位来实现的。目的是为了与48位的子密钥进行异或同时让输出的一位能影响到下一轮多个S盒的输入实现“扩散”。与子密钥异或将扩展后的48位结果与48位的子密钥K进行按位异或。S盒替代S-box这是DES中唯一的非线性变换是算法保密性的核心。将异或后的48位数据分成8组每组6位送入8个不同的S盒。每个S盒是一个固定的4行16列的查找表它接收6位输入输出4位。这一步将48位数据压缩回32位并且提供了至关重要的非线性特性。P盒置换P-box将S盒输出的32位数据进行一次固定的位置置换进一步打乱数据位之间的关系。2.3 密钥调度从主密钥到轮密钥DES的加密强度很大程度上依赖于每一轮使用的子密钥都不同。密钥调度算法负责从56位有效密钥64位密钥去掉奇偶校验位生成16个48位的子密钥。置换选择1PC-1首先64位初始密钥经过PC-1置换去掉8位奇偶校验位并对剩下的56位进行位置重排生成C0和D0各28位。循环左移在每一轮C和D分别进行循环左移移位数根据轮数而定第1、2、9、16轮左移1位其余轮左移2位。置换选择2PC-2将移位后的C和D合并成56位再经过PC-2置换压缩并重排最终输出48位的本轮子密钥。注意所有置换表IP, IP-1, E, P, PC-1, PC-2和S盒都是公开的、固定的。我们的实现就是将这些表格“翻译”成C中的数组并严格按照其定义进行位操作。3. C实现的核心数据结构与设计思路用C实现DES本质上是在用程序语言精确地描述上述的数学和逻辑过程。选择合适的数据结构来“表示”位和数据块是决定代码是否清晰、高效的关键。3.1 数据表示为何选择std::bitset在C中处理位级操作有几种选择unsigned long long配合位运算符、std::vectorbool、或者std::bitset。对于DES这种固定位长的算法我强烈推荐使用std::bitsetN。类型安全与长度固定bitset64明确表示一个64位的数据块bitset48表示48位子密钥。编译器会在编译期确保长度避免了运行时越界的风险意图表达非常清晰。丰富的位操作它重载了所有的位运算符,|,^,~,,并且提供test(),set(),reset(),flip()等成员函数操作比特位比直接操作整数更直观。易于调试你可以直接cout一个bitset对象它会以“00101101...”这样的二进制字符串形式输出在调试跟踪数据流时无比方便。当然你也可以使用uint64_t通过掩码和移位来操作特定位性能可能稍好但代码的可读性和可维护性会大打折扣。对于学习和理解算法而言清晰比那一点性能更重要。3.2 核心类设计一个良好的面向对象设计能让代码结构清晰。我建议设计一个DES类将加密解密的核心逻辑封装起来。class DES { public: DES(const std::string key); // 构造函数接受字符串密钥 std::string encrypt(const std::string plaintext); std::string decrypt(const std::string ciphertext); private: std::bitset64 key; // 存储初始密钥64位含校验 std::vectorstd::bitset48 roundKeys; // 存储16轮子密钥 // 内部核心过程 void generateRoundKeys(); // 密钥调度 std::bitset32 feistel(const std::bitset32 R, const std::bitset48 K); // 轮函数F std::bitset64 processBlock(const std::bitset64 block, bool isEncrypt); // 处理单个64位块 // 置换函数工具函数 templatesize_t IN, size_t OUT std::bitsetOUT permute(const std::bitsetIN input, const int table[OUT]); };设计思路解析构造函数接受一个字符串密钥。我们需要将其转换为bitset64。如果密钥长度不足8字节需要填充如果超过可以截取或使用哈希衍生。这里简单处理可以取前8字节。generateRoundKeys这是一个关键私有函数在构造函数或首次加密时被调用生成16轮子密钥并存入roundKeys向量避免每次加密重复计算。processBlock加密和解密的公共部分。参数isEncrypt用于控制是使用roundKeys[0]到roundKeys[15]加密还是roundKeys[15]到roundKeys[0]解密。这完美体现了Feistel网络的优势。模板化置换函数这是一个技巧。IP、E、P、PC-1、PC-2置换都是同样的操作根据一张表将输入特定位映射到输出特定位。我们可以写一个模板函数通过IN和OUT模板参数适应不同大小的输入输出table参数传入对应的置换表数组。这能极大减少重复代码。3.3 置换表与S盒的代码化这是最“体力”但也必须最细心的一部分。你需要将标准文档中的置换表和S盒逐行翻译成C的二维数组。例如初始置换IP表const int IP_Table[64] { 58, 50, 42, 34, 26, 18, 10, 2, 60, 52, 44, 36, 28, 20, 12, 4, // ... 其余56个数字 };注意表格中的数字通常是从1开始计数的表示输入数据块的第N位。而std::bitset的索引是从0开始的最右边是第0位。这是一个极易出错的点你需要在置换函数中做减1转换或者直接定义表格时就用0起始的索引。我强烈建议采用后者即根据算法描述先减1再填入数组让代码逻辑更直接。S盒的定义更复杂一些它是一个8x4x16的三维数组8个盒子每个盒子4行16列。const int S_Box[8][4][16] { // S1 { {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} }, // S2 ... S8 以此类推 };使用S盒时6位输入的第一位和最后一位组成行号0-3中间4位组成列号0-15然后查找对应的4位输出值。4. 分步实现与关键代码剖析有了清晰的设计和数据结构我们就可以开始动手实现了。整个过程就像搭积木从最小的置换函数开始逐步构建轮函数最后完成整个加密流程。4.1 基础工具通用置换函数的实现这是所有置换操作的基础。我们利用C模板写一个函数处理所有情况。templatesize_t IN, size_t OUT std::bitsetOUT DES::permute(const std::bitsetIN input, const int table[OUT]) { std::bitsetOUT result; for (size_t i 0; i OUT; i) { // table[i] 表示输出位i的值来自输入位的第 table[i] 位。 // 假设我们的table已经是以0为起始索引定义的。 size_t originalPos table[i]; if (originalPos IN input.test(originalPos)) { result.set(i); } } return result; }这个函数遍历输出位的每一个位置i查看置换表table[i]指定的输入位是否为1如果是则将输出位i设为1。test()和set()是bitset的成员函数分别用于测试和设置特定位。4.2 密钥调度算法的实现在构造函数中调用generateRoundKeys()。void DES::generateRoundKeys() { roundKeys.clear(); // 1. PC-1置换56位有效密钥 std::bitset56 pc1Key permute64, 56(key, PC1_Table); // 分割成C0和D0各28位 std::bitset28 C (pc1Key 28).to_ulong(); // 取高28位 std::bitset28 D (pc1Key.to_ulong() 0x0FFFFFFF); // 取低28位通过掩码 // 每轮的左移位数表 const int shiftTable[16] {1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1}; for (int i 0; i 16; i) { // 2. 循环左移 C (C shiftTable[i]) | (C (28 - shiftTable[i])); D (D shiftTable[i]) | (D (28 - shiftTable[i])); // 3. 合并并PC-2置换生成48位子密钥 std::bitset56 combinedKey; // 将C28位和D28位合并回56位。需要一些位操作技巧。 // 一种方法是先转成unsigned long long再拼接更清晰的方法是逐位设置。 // 这里为了清晰使用一个辅助函数或直接操作。 // 假设我们有一个合并函数 mergeBitsets std::bitset56 CD mergeBitsets(C, D); // C放在高28位D放在低28位 std::bitset48 roundKey permute56, 48(CD, PC2_Table); roundKeys.push_back(roundKey); } }实操心得bitset的和运算符是逻辑移位对于固定位长的bitset超出部分会被丢弃另一侧补0。这正是我们想要的循环左移效果先左移再或上右移的“溢出”部分。但要注意bitset的to_ulong()在值超出unsigned long范围时会抛出异常在处理大bitset时要小心。对于28位的C和D其值肯定在unsigned long范围内所以是安全的。4.3 轮函数F的实现这是算法的灵魂所在需要严格按照扩展、异或、S盒、P盒的顺序实现。std::bitset32 DES::feistel(const std::bitset32 R, const std::bitset48 K) { // 1. 扩展置换 E: 32 - 48 std::bitset48 expandedR permute32, 48(R, E_Table); // 2. 与子密钥异或 std::bitset48 xored expandedR ^ K; // 3. S盒替代: 48 - 32 std::bitset32 sBoxOutput; int sBoxPos 0; for (int i 0; i 8; i) { // 取出6位 int block (xored (42 - i*6)).to_ulong() 0x3F; // 每次取6位注意bitset的索引方向 // 计算行号和列号 int row ((block 0x20) 4) | (block 0x01); // 第一位和最后一位 int col (block 1) 0x0F; // 中间四位 // 查找S盒 int sBoxValue S_Box[i][row][col]; // 将4位输出拼接到结果中 sBoxOutput 4; // 左移4位为新结果腾出空间 sBoxOutput | std::bitset32(sBoxValue); } // 注意上面的拼接逻辑需要根据bitset的位序调整。更稳妥的方法是逐位设置。 // 另一种清晰的做法是先计算好sBoxValue然后从第 (31 - i*4) 位开始设置4位。 // 4. P盒置换 std::bitset32 result permute32, 32(sBoxOutput, P_Table); return result; }关键细节与避坑S盒处理是最大的难点。一是位序问题bitset的operator是向低位移动而我们在概念上通常把最高位写在左边。在取6位块和拼接4位输出时必须非常清楚当前数据的位序。我建议在关键步骤插入调试输出打印出bitset的二进制字符串对照算法手册逐步验证。二是S盒的行列计算一定要确认算法描述中是如何用6位输入定位的不同的资料可能索引方式略有不同。4.4 主流程单个数据块的加密/解密processBlock函数串联起所有步骤。std::bitset64 DES::processBlock(const std::bitset64 block, bool isEncrypt) { // 1. 初始置换IP std::bitset64 permutedBlock permute64, 64(block, IP_Table); // 2. 分割成L0和R0 std::bitset32 L (permutedBlock 32).to_ulong(); std::bitset32 R permutedBlock.to_ulong() 0xFFFFFFFF; // 3. 16轮Feistel迭代 for (int i 0; i 16; i) { std::bitset32 oldL L; L R; // 决定使用第几轮子密钥 int keyIndex isEncrypt ? i : 15 - i; R oldL ^ feistel(R, roundKeys[keyIndex]); } // 4. 最后交换第16轮后不交换但算法描述中通常先交换再合并这里在循环中已经完成交换 // 合并 R16 和 L16 (注意经过16轮后L和R已经是R16和L16) std::bitset64 combinedBlock; // 将R作为高32位和L作为低32位合并。需要位操作。 // 例如combinedBlock (std::bitset64(R.to_ulong()) 32) | std::bitset64(L.to_ulong()); // 5. 末置换IP-1 std::bitset64 outputBlock permute64, 64(combinedBlock, IP1_Table); return outputBlock; }4.5 外围工作模式与填充一个完整的加密程序不能只处理恰好64位8字节的数据。我们需要处理任意长度的明文并选择合适的分组工作模式如ECB、CBC和填充方式如PKCS#7。ECB模式电子密码本最简单每个块独立加密。缺点是相同的明文块会生成相同的密文块模式化明显不安全。实现简单直接分割、填充、加密每个块即可。CBC模式密码分组链接更安全。每个明文块在加密前先与前一个密文块或初始向量IV进行异或。这破坏了模式的重复性。解密时需要先解密再与上一个密文块异或。强烈建议在实际学习项目中至少实现CBC模式它能让你理解初始化向量IV的重要性。填充如果数据不是8字节的整数倍需要在末尾填充。PKCS#7是常用标准如果缺n个字节就填充n个值为n的字节。例如数据差3字节则填充0x03 0x03 0x03。实现一个带CBC模式和PKCS#7填充的encrypt函数std::string DES::encrypt(const std::string plaintext) { // 1. PKCS#7填充 size_t padLen 8 - (plaintext.length() % 8); if (padLen 0) padLen 8; // 如果长度正好是8的倍数额外填充一个完整块 std::string paddedText plaintext std::string(padLen, static_castchar(padLen)); // 2. 生成随机初始化向量IV这里用固定值示例实际应用必须用密码学安全的随机数 std::bitset64 iv(0x0123456789ABCDEFULL); std::string ciphertext; std::bitset64 previousBlock iv; // 3. CBC模式加密 for (size_t i 0; i paddedText.length(); i 8) { // 将8字节字符串转换为64位bitset uint64_t blockData 0; memcpy(blockData, paddedText.data() i, 8); std::bitset64 plainBlock(blockData); // CBC: 明文块与上一个密文块或IV异或 plainBlock ^ previousBlock; // 加密 std::bitset64 cipherBlock processBlock(plainBlock, true); // 将密文块转换为字符串并追加 uint64_t cipherValue cipherBlock.to_ullong(); ciphertext.append(reinterpret_castchar*(cipherValue), 8); // 更新“上一个密文块” previousBlock cipherBlock; } // 4. 返回密文通常是二进制数据可以Base64编码后返回字符串 return ciphertext; // 注意这是二进制字符串 }解密函数是逆过程需要注意填充的移除。5. 测试、验证与性能考量实现完成后必须进行严格的测试。5.1 使用标准测试向量验证NIST或其他标准机构提供了DES的已知答案测试KAT向量。找一组标准的密钥明文密文三元组用你的程序加密明文看结果是否与标准密文一致再用你的程序解密密文看是否能还原明文。这是验证算法实现正确性的黄金标准。void testDES() { DES des(01234567); // 8字节密钥 std::string plain helloDES; std::string cipher des.encrypt(plain); std::string decrypted des.decrypt(cipher); std::cout Plain: plain std::endl; std::cout Cipher (hex): ; for (char c : cipher) printf(%02X , (unsigned char)c); std::cout std::endl; std::cout Decrypted: decrypted std::endl; // 对比decrypted和plain并去除填充后是否一致 }5.2 常见问题与调试技巧结果完全不对首先检查所有置换表、S盒的数据是否录入错误。这是最常见的问题。建议写一个小程序用已知的输入比如全0或全1数据手动计算一轮并打印出每一步的中间结果二进制形式与标准计算过程对比。只有最后几位不对很可能是位序Endian或拼接顺序问题。在合并左右半部分、处理S盒输入输出时要特别注意最高位MSB和最低位LSB在bitset和你的思维模型中的对应关系。bitset的operator是向高位移动输出时bitset的字符串表示是高位在左。加密解密不互逆检查Feistel轮函数在加密和解密时子密钥的使用顺序是否正确。加密用K0~K15解密必须用K15~K0。检查初始置换IP和末置换IP-1是否互为逆过程。多块数据时出错检查填充逻辑是否正确特别是在解密后移除填充时是否正确地读取了最后一个字节的值并验证了填充的合法性。检查CBC模式中IV的处理加解密双方必须使用相同的IV。5.3 性能与优化思考我们使用std::bitset的实现重在清晰和教育意义但性能并非最优。bitset的位操作是安全的但可能不是最快的。生产级别的C实现可能会使用uint64_t和uint32_t等基本类型通过掩码和移位直接操作。将置换操作实现为查表法Look-up Table尤其是将多个步骤如E盒S盒P盒合并成一张大表用空间换时间。这是许多加密库的优化手段。使用编译器内部函数Intrinsics或SIMD指令进行并行优化。但无论如何优化DES本身56位密钥在当今计算能力下已不再安全绝对不应用于实际的敏感数据加密。这个项目的价值在于学习原理。5.4 从DES到3DES和AES的延伸理解了DES再去看3DESTriple DES就非常容易了。它本质上就是用两个或三个密钥对数据块进行三次DES加密加密-解密-加密以此来增加有效密钥长度对抗暴力破解。你可以尝试修改你的DES类轻松封装出一个3DES类。而AESRijndael算法虽然不再是Feistel结构而是SPN代换-置换网络结构但其设计思想——字节替代SubBytes、行移位ShiftRows、列混合MixColumns、轮密钥加AddRoundKey——与DES的混淆、扩散一脉相承。实现过DES后你会对状态State、轮密钥扩展等概念有更直观的理解再学习AES会事半功倍。最后我个人在实现这个项目时最大的体会是密码学算法的实现就像在代码中构建一座精密的机械钟表。每一个置换表、每一次移位、每一个S盒查找都必须分毫不差。调试过程虽然繁琐但当你的程序第一次成功通过标准测试向量时那种透过代码窥见数学之美和设计者智慧的感觉是无与伦比的。它锻炼的不仅仅是编程能力更是极端严谨的逻辑思维和对细节的掌控力。如果你在实现过程中卡住了不妨放下代码拿起笔和纸手动演算一小轮很多时候答案就在那一步步的推算之中。