☰
DES MFC加密工程:五种工作模式与C++内核实现
2026/10/26 13:02:16 网站建设 项目流程

简介:这是一份面向密码学课程实验、DES算法学习与MFC桌面开发练习的源码资料,围绕一重DES的ECB、CBC、CFB、OFB、CTR五种工作模式给出可运行的图形界面实现,适合需要理解分组密码模式差异、完成课设或扩展三重DES的读者参考。压缩包共18个文件,约57KB,包含h头文件、cpp实现、rc资源脚本、exe可执行程序及txt说明等,分别对应界面资源、核心加解密逻辑与运行验证,体积小巧便于查阅和二次开发。代码配有注释,MFC界面友好,可对文件进行加密与解密,退出时的提示不影响实际功能;借助现有结构可较方便地迁移到三重DES。目前已有1091人学习,适合作为算法验证与工程化封装的入门样例。

1. 从一份十六进制密文说起:这个 DES MFC 加密工程解决什么问题

对接方发来一串 32 位十六进制密文和一句“密钥就是 12345678”,要求在一台只能跑 Windows 的旧机器上把它解出来。翻遍手头仓库,找到的就是这份 DES MFC 加密源码包。它把 ECB、CBC、CFB、OFB、CTR 五种工作模式放在同一个对话框里:左边下拉框选模式,填明文、密钥、初始向量,右边直接输出十六进制结果。对需要给老 MFC 客户端补一个加解密入口的人来说,这份工程把界面外壳和算法内核分离得很干净,DES.h/DES.cpp不引用任何 MFC 类型,提取出来放进控制台程序、服务端程序都能直接编译。它不链接第三方密码库,密钥调度、S 盒、置换表全部由手写位运算实现,适合对照课本公式理解每一步到底改动了哪些位,也适合作为老项目里替换自研加密模块的底稿。

2. 工程骨架:对话框外壳与纯 C++ DES 内核怎么拆

2.1 目录结构与两层职责

这个工程是典型的 MFC 对话框应用,用 VS 新建“基于对话框”的 MFC 项目之后再往里加算法文件。目录结构大致是这样:

DesDemo/ ├── DesDemo.sln └── DesDemo/ ├── DesDemo.h / DesDemo.cpp 应用类,只有一个 InitInstance ├── DesDemoDlg.h / DesDemoDlg.cpp 主对话框,所有控件响应都在这 ├── DES.h 内核对外接口 + 模式枚举 ├── DES.cpp 置换表、S 盒、密钥调度、五种模式 ├── StdAfx.h / StdAfx.cpp 预编译头 ├── resource.h └── DesDemo.rc 对话框资源,控件 ID 都在这里

分层的分寸把握得很好:DES.h只依赖<cstdint>、<vector>、<string>,一个afx开头的头文件都没有,DES.cpp里连CString都不出现。这意味着整个算法层可以单独拿出来塞进一个 Win32 控制台工程编译通过,不需要 MFC 运行时。对话框类CDesDemoDlg负责三件事:从控件读字符串、调内核、把vector<uint8_t>转成十六进制回填到编辑框。职责边界清楚了,后续换界面框架(比如换成 Qt 或者直接命令行的main)时,只需要重写这一层胶水。

常见做法是把模式枚举也放在DES.h里,界面层用下拉框的GetCurSel()返回值直接转成枚举,省掉一层字符串匹配:

// DES.h —— 内核对外接口,完全不依赖 MFC #pragma once #include <cstdint> #include <vector> // 下拉框索引 0~4 与枚举值一一对应,界面层直接强转 enum DesMode { MODE_ECB = 0, MODE_CBC = 1, MODE_CFB = 2, MODE_OFB = 3, MODE_CTR = 4 }; class CDesCore { public: // key 必须 8 字节;iv 在 ECB 下忽略,其余模式必须 8 字节 // 返回值长度:ECB/CBC 为补齐后的长度,CFB/OFB/CTR 与输入等长 static std::vector<uint8_t> Encrypt(const std::vector<uint8_t>& plain, const std::vector<uint8_t>& key, DesMode mode, std::vector<uint8_t>& iv); static std::vector<uint8_t> Decrypt(const std::vector<uint8_t>& cipher, const std::vector<uint8_t>& key, DesMode mode, std::vector<uint8_t>& iv); private: static void KeySchedule(const uint8_t key[8], uint8_t subKey[16][6]); static void DesBlock(const uint8_t in[8], uint8_t out[8], const uint8_t subKey[16][6], bool enc); static uint32_t RoundFunc(uint32_t r, const uint8_t subKey[6]); };

头文件里两个细节值得注意。第一,iv用的是非 const 引用,因为 CBC 加密过程中需要把上一块密文写回 IV,调用方如果希望复用同一份 IV,得自己拷贝一份,这个设计有点“副作用藏得深”,后面第 5 章会专门说。第二,Encrypt的返回值长度在两种分组模式下会跟输入不一样,因为有 PKCS#7 补齐;流模式则严格等长,这一点在界面上必须提示用户,否则把 CFB 的输出当成定长密文去截断就会解密失败。

2.2 编译环境与工程配置

能跑起来的最小环境是 Windows + Visual Studio 2017 或 2019,安装时勾上“使用 C++ 的桌面开发”里那一项“适用于最新 v142 生成工具的 C++ MFC”。这一步漏了,打开工程第一件事就是满屏的Cannot open include file: 'afxwin.h'。工程属性里几个必看项:

配置项建议值说明
字符集使用 Unicode 字符集MFC 默认值,内核按字节处理,转换在自己代码里做
MFC 使用方式在共享 DLL 中使用 MFC生成的 exe 小,但目标机要有 mfc140u.dll
平台工具集Visual Studio 2017 (v141) 或更高老项目迁移过来注意 SDK 版本
C++ 语言标准ISO C++14 或更高内核用了uint8_t、vector,C++11 起即可

有一点容易被忽略:如果用“在静态库中使用 MFC”,生成的 exe 会大出好几 MB,但好处是拷到没装运行库的机器上直接双击能跑。给现场运维发版本的时候,我一般选静态库,省掉一轮“缺少 dll”的远程支持。字符集选 Unicode 之后,CString是宽字符,往std::vector<uint8_t>里塞要经过一次WideCharToMultiByte,不能直接memcpy。转换函数写成这样:

// 把 CString 按 UTF-8 转成字节数组,中文不会被截成半个字 static std::vector<uint8_t> ToUtf8Bytes(const CString& str) { if (str.IsEmpty()) return {}; int len = WideCharToMultiByte(CP_UTF8, 0, str, str.GetLength(), nullptr, 0, nullptr, nullptr); std::vector<uint8_t> buf(len); WideCharToMultiByte(CP_UTF8, 0, str, str.GetLength(), reinterpret_cast<char*>(buf.data()), len, nullptr, nullptr); return buf; }

WideCharToMultiByte调了两次是标准写法:第一次传空指针拿长度,第二次才真正写缓冲区。CP_UTF8决定了中文在内存里占 3 个字节,跟对接方约定的编码必须一致,否则密文解出来是乱码而不是错密文,这种问题排查起来最费劲。

2.3 界面到内核的数据流

按钮响应函数是整条链路的枢纽。它把“用户填的东西”翻译成“内核要的东西”,顺序不能乱:

// 加密按钮响应:读控件 -> 校验 -> 调内核 -> 回填十六进制 void CDesDemoDlg::OnBnClickedBtnEncrypt() { CString strPlain, strKey, strIv; m_edtPlain.GetWindowText(strPlain); m_edtKey.GetWindowText(strKey); // 密钥编辑框 m_edtIv.GetWindowText(strIv); // IV 编辑框,ECB 下可留空 // 密钥必须是 8 字节,长度不对直接拦下来,不要进内核 std::vector<uint8_t> key = ToUtf8Bytes(strKey); if (key.size() != 8) { AfxMessageBox(_T("密钥必须正好 8 个字节")); return; } std::vector<uint8_t> plain = ToUtf8Bytes(strPlain); std::vector<uint8_t> iv = ToUtf8Bytes(strIv); DesMode mode = static_cast<DesMode>(m_cmbMode.GetCurSel()); // ECB 不需要 IV,其余模式缺了就补 8 个 0 if (mode != MODE_ECB && iv.size() != 8) { iv.assign(8, 0); } std::vector<uint8_t> cipher = CDesCore::Encrypt(plain, key, mode, iv); m_edtOutput.SetWindowText(BytesToHex(cipher)); // 输出一律小写十六进制 }

这段代码里有三个约定,必须在界面上写清楚。第一,密钥按“字符的 UTF-8 字节”算,不是按用户看到的字符数算,一个中文密钥字符占 3 字节,12345678正好 8 字节所以没问题,但填密码123就超了。第二,ECB 的 IV 直接被丢掉,其余模式缺 IV 时补零——补零只是让程序不崩,安全性上等于没有 IV,生产环境必须让用户显式填一个随机值。第三,输出统一小写十六进制,BytesToHex里用%02x拼字符串,两两一对,方便对接方直接复制。

BytesToHex本身很简单,但有一个容易出错的地方:CString::Format在循环里反复拼接性能很差,明文超过几 KB 就会卡界面。常见做法是先用std::string拼完再整体转成CString:

// 字节数组转十六进制字符串,先用 std::string 累积再整体转换 static CString BytesToHex(const std::vector<uint8_t>& data) { static const char* HEX = "0123456789abcdef"; std::string s; s.reserve(data.size() * 2); for (uint8_t b : data) { s.push_back(HEX[b >> 4]); // 高 4 位 s.push_back(HEX[b & 0x0F]); // 低 4 位 } return CString(s.c_str(), s.size()); // 显式给长度,避免中间出现 0 被截断 }

CString那个构造函数第二个参数给长度是关键:密文字节里完全可能出现0x00,如果只传c_str(),CString会在第一个零字节处停住,后面直接少一截,加密“看起来成功”但密文缺了尾巴,对接方那边一解就崩。

3. DES 内核拆解:从一个 8 字节密钥走到 16 组子密钥

3.1 置换与位操作:内核的公共底座

DES 的每一步几乎都是“按一张表重新排列比特位”,所以内核里最先要写的是一个通用的置换函数。表的含义是:新数据的第 i 位,取自原数据的第table[i]位(表里用 1 开始编号,代码里要减 1)。手写实现通常是这样的:

// 通用置换:从 64 位输入里按 table 取位,拼成 outBits 位输出 // table 元素是 1-based 的位号,inputBits 是输入有效位宽 static uint64_t Permute(uint64_t input, const uint8_t* table, int inputBits, int outBits) { uint64_t out = 0; for (int i = 0; i < outBits; ++i) { int src = table[i] - 1; // 转成 0-based uint64_t bit = (input >> (inputBits - 1 - src)) & 1ULL; out = (out << 1) | bit; // 逐位左移拼装 } return out; }

inputBits - 1 - src这个写法对应“最高位是第 1 位”的编号习惯,跟 DES 标准文档里的表一致。如果改成从低位往高位拼,表就得整个反转,两边必须统一,否则加解密结果对不上还查不出原因。inputBits和outBits分开传,是因为 DES 里既有 64→56 的 PC-1,也有 32→48 的 E 扩展、56→48 的 PC-2,一个函数全包了。

字节和uint64_t之间的转换也要成对写,并且固定用大端序,这一点在跨平台对接时是必须满足的条件:

// 8 字节 <-> uint64_t,统一大端,保证和标准测试向量一致 static uint64_t BytesToU64(const uint8_t b[8]) { uint64_t v = 0; for (int i = 0; i < 8; ++i) v = (v << 8) | b[i]; return v; } static void U64ToBytes(uint64_t v, uint8_t b[8]) { for (int i = 7; i >= 0; --i) { b[i] = (uint8_t)(v & 0xFF); v >>= 8; } }

3.2 密钥调度:16 组 48 位子密钥怎么来

密钥调度是整个 DES 里最容易被跳过、又最容易写错的一段。流程只有三步:PC-1 把 64 位原始密钥压成 56 位(丢掉 8 个校验位),拆成左右各 28 位;每一轮按移位表循环左移 1 或 2 位;PC-2 从 56 位里挑 48 位作为本轮子密钥。代码长这样:

// 密钥调度:8 字节原始密钥 -> 16 组 48 位子密钥 // subKey[i] 为 6 字节,按大端存放本轮 48 位 void CDesCore::KeySchedule(const uint8_t key[8], uint8_t subKey[16][6]) { uint64_t k64 = BytesToU64(key); k64 = Permute(k64, PC1, 64, 56); // 64 -> 56,丢掉校验位 uint32_t c = (uint32_t)((k64 >> 28) & 0x0FFFFFFF); // 高 28 位 uint32_t d = (uint32_t)(k64 & 0x0FFFFFFF); // 低 28 位 static const int SHIFTS[16] = {1,1,2,2,2,2,2,2,1,2,2,2,2,2,2,1}; for (int i = 0; i < 16; ++i) { int s = SHIFTS[i]; // 28 位循环左移,掩码防止高位溢出 c = ((c << s) | (c >> (28 - s))) & 0x0FFFFFFF; d = ((d << s) | (d >> (28 - s))) & 0x0FFFFFFF; uint64_t cd = ((uint64_t)c << 28) | d; // 拼回 56 位 uint64_t sk = Permute(cd, PC2, 56, 48); // 56 -> 48 for (int b = 0; b < 6; ++b) subKey[i][b] = (uint8_t)((sk >> (40 - b * 8)) & 0xFF); } }

SHIFTS表里只有第 1、2、9、16 轮移 1 位,其余轮移 2 位,这个序列记错了,密钥就对不上,但加密解密自己跟自己对得上,属于典型的“自己测不出来、跟别人一对就出事”的错误。c >> (28 - s)在s == 0时会变成右移 28 位,虽然移位表里没有 0,写的时候还是建议加个判断,别省这几行。

Permute传入的inputBits是 56 不是 64,因为cd拼出来只有 56 个有效位,高位是 0。传 64 的话,PC-2 表里 57~64 的位号取到的全是 0,子密钥整体右移 8 位,问题同样是“自测通过、对接失败”。

3.3 轮函数与单块加解密

单块加密是 DES 的主体:初始置换 IP、16 轮 Feistel、末尾的逆置换 IP⁻¹。每一轮做的是“右半部分进轮函数 F,输出跟左半部分异或,然后左右交换”。轮函数 F 内部先做 E 扩展把 32 位拉成 48 位,跟子密钥异或,再切成 8 组 6 位进 S 盒,最后 P 置换。S 盒那一步是唯一不可省略的“黑匣子”,行号和列号取法有个小窍门:

// 轮函数:32 位右半部分与 48 位子密钥混合 uint32_t CDesCore::RoundFunc(uint32_t r, const uint8_t subKey[6]) { uint64_t e = Permute(r, E, 32, 48); uint64_t sk = 0; for (int i = 0; i < 6; ++i) sk = (sk << 8) | subKey[i]; uint64_t x = e ^ sk; // 48 位异或结果 uint32_t s = 0; for (int i = 0; i < 8; ++i) { // 每 6 位一组:第 1、6 位组成行号,中间 4 位是列号 int bits = (int)((x >> (42 - i * 6)) & 0x3F); int row = ((bits & 0x20) >> 4) | (bits & 0x01); int col = (bits >> 1) & 0x0F; s = (s << 4) | S_BOX[i][row][col]; // 每组输出 4 位 } return (uint32_t)Permute(s, P, 32, 32); }

x >> (42 - i * 6)里的 42 是 48 − 6,表示第一组 6 位从最高位往低位数。行号那行(bits & 0x20) >> 4取的是最高位(第 6 位)作为行号的高位,bits & 0x01取最低位作为行号低位,合并成 0~3 的行号;列号则是中间 4 位。取反了会得到完全不同的结果,而且依然能“跑通”,只是密文跟标准向量对不上。

单块加解密写在一起,靠一个布尔开关切换:

// 单块加密/解密:8 字节进 8 字节出 // enc 为 true 时按正常轮序,false 时子密钥逆序使用 void CDesCore::DesBlock(const uint8_t in[8], uint8_t out[8], const uint8_t subKey[16][6], bool enc) { uint64_t m = BytesToU64(in); m = Permute(m, IP, 64, 64); // 初始置换 uint32_t l = (uint32_t)(m >> 32); // 高 32 位为左 uint32_t r = (uint32_t)(m & 0xFFFFFFFF);// 低 32 位为右 for (int i = 0; i < 16; ++i) { int round = enc ? i : (15 - i); // 解密只是子密钥倒着用 uint32_t next = l ^ RoundFunc(r, subKey[round]); l = r; r = next; } // 注意最后一轮后左右不交换,直接按 R||L 顺序拼 uint64_t pre = ((uint64_t)r << 32) | l; pre = Permute(pre, IP_INV, 64, 64); // 逆初始置换 U64ToBytes(pre, out); }

两个细节必须留意。第一,循环结束后是R || L而不是L || R,因为最后一轮按标准定义不做交换,这个“少交换一次”的约定几乎每个人都踩过一次坑,症状是自测加解密能对上(因为解密时同样错),但跟标准测试向量一比就全错。第二,解密函数一行都不用另写,把 16 轮的子密钥按 15→0 的顺序用即可,这是 Feistel 结构的对称性决定的,不是凑巧。

注意:验证内核是否正确的唯一可靠办法是拿公开的测试向量跑一遍,比如明文0123456789ABCDEF、密钥133457799BBCDFF1,标准 ECB 加密结果是85E813540F0AB405。自己加自己解永远能对上,对不上标准向量的实现都是在并行世界里活着。

4. 五种工作模式:ECB、CBC、CFB、OFB、CTR 的代码差异在哪

4.1 分组模式 ECB 与 CBC:块与块之间的链接

ECB 最简单,明文按 8 字节切块,每块独立调DesBlock,块与块之间毫无关系。它的毛病也是因此来的:相同的明文块会产出相同的密文块,一张图片加密完还能看出轮廓。代码短到只有几行:

// ECB:每块独立加密,块间无关联 for (size_t off = 0; off < plain.size(); off += 8) { DesBlock(&plain[off], &out[off], subKey, true); }

CBC 在 ECB 上加了一次异或:每块进DesBlock之前先跟“上一块密文”异或,第一块跟 IV 异或。加密和解密的异或对象不一样,是这块代码最容易被抄错的地方:

// CBC 加密:明文 ^ 上一块密文,第一块用 IV uint8_t prev[8]; memcpy(prev, iv.data(), 8); // 初始向量,调用方负责随机 for (size_t off = 0; off < plain.size(); off += 8) { uint8_t blk[8]; for (int k = 0; k < 8; ++k) blk[k] = plain[off + k] ^ prev[k]; DesBlock(blk, &out[off], subKey, true); memcpy(prev, &out[off], 8); // 密文成为下一块的"前一块" } // CBC 解密:先解块,再跟上一块密文异或 for (size_t off = 0; off < cipher.size(); off += 8) { uint8_t dec[8]; DesBlock(&cipher[off], dec, subKey, false); for (int k = 0; k < 8; ++k) out[off + k] = dec[k] ^ prev[k]; memcpy(prev, &cipher[off], 8); // 注意这里拷的是密文,不是明文 }

解密那一行memcpy(prev, &cipher[off], 8)是关键字,拷错了(比如顺手写成&out[off])第一块能对、后面全乱。CBC 的 IV 必须随机且不可预测,同一把密钥配同一个 IV 反复用,等于把 ECB 的弱点换个形式又请回来了。

4.2 流模式 CFB 与 OFB:把块密码当密钥流用

CFB 和 OFB 的思路是把块密码改造成“密钥流发生器”,每生成 8 字节密钥流就跟明文异或。好处是密文长度等于明文长度,不用补齐,适合加密不定长数据。

CFB 加密时用“上一块密文”当输入喂给DesBlock,输出跟当前明文异或;解密时输入换回“上一块密文”,因为接收方手里也有密文:

// CFB 加密(8 位反馈):密钥流 = E(上一块密文) uint8_t feedback[8]; memcpy(feedback, iv.data(), 8); for (size_t off = 0; off < plain.size(); off += 8) { uint8_t ks[8]; DesBlock(feedback, ks, subKey, true); // 生成密钥流 for (int k = 0; k < 8; ++k) out[off + k] = plain[off + k] ^ ks[k]; memcpy(feedback, &out[off], 8); // 密文回填反馈寄存器 } // CFB 解密:同样的密钥流生成,异或对象换成密文 for (size_t off = 0; off < cipher.size(); off += 8) { uint8_t ks[8]; DesBlock(feedback, ks, subKey, true); // 注意这里仍然是"加密"方向 for (int k = 0; k < 8; ++k) out[off + k] = cipher[off + k] ^ ks[k]; memcpy(feedback, &cipher[off], 8); // 密文回填,跟加密一致 }

注意解密时DesBlock第三个参数仍然是true,CFB 用的是加密方向生成密钥流,这一点跟 CBC 正相反。很多人第一反应是把解密写成false,结果解出来是乱码。

OFB 把反馈路径改成了“密钥流自己反馈”,跟明文密文都没关系:

// OFB:反馈寄存器 = E(反馈寄存器),与明文无关 for (size_t off = 0; off < plain.size(); off += 8) { DesBlock(feedback, feedback, subKey, true); // 就地更新反馈值 for (int k = 0; k < 8; ++k) out[off + k] = plain[off + k] ^ feedback[k]; }

OFB 的优点是加密解密完全一样(同样一段代码,输入换一下方向就行),缺点是一旦某个密钥流块出错,后面所有块全废,没有自同步能力。

4.3 CTR 模式与 PKCS#7 填充

CTR 把“反馈”换成了“计数器”:维护一个 8 字节计数器,每次加密计数器值得到密钥流,然后计数器加一。它不依赖上一块,理论上可以并行,但这份工程里是按顺序实现的:

// CTR:密钥流 = E(计数器),计数器每块自增 uint64_t ctr = BytesToU64(iv.data()); // 计数器初值就是 IV for (size_t off = 0; off < plain.size(); off += 8) { uint8_t cb[8], ks[8]; U64ToBytes(ctr, cb); DesBlock(cb, ks, subKey, true); size_t n = std::min<size_t>(8, plain.size() - off); // 尾块可能不足 8 for (size_t k = 0; k < n; ++k) out[off + k] = plain[off + k] ^ ks[k]; ++ctr; // 计数器递增 }

std::min那行是为了处理末尾不足 8 字节的情况,CTR 天然支持“最后一块缺几个字节就只异或几个字节”,不需要补齐。真正需要补齐的只有 ECB 和 CBC:

// PKCS#7 补齐:缺 n 个字节就补 n 个 0x0n static void PadPkcs7(std::vector<uint8_t>& data) { size_t pad = 8 - (data.size() % 8); data.insert(data.end(), pad, (uint8_t)pad); // 补齐值 = 补齐长度 } // 去补齐:读最后一个字节,校验后截掉 static bool UnpadPkcs7(std::vector<uint8_t>& data) { if (data.empty()) return false; uint8_t pad = data.back(); if (pad == 0 || pad > 8 || pad > data.size()) return false; data.resize(data.size() - pad); return true; }

UnpadPkcs7里的校验不能省:如果密钥错了,解出来的最后一个字节可能是任意值,直接按它去截断,可能得到一个负数长度或者截出个残缺数据。

提示:CTR 模式下计数器初值绝对不能重复使用,同一把密钥下两次加密用了同一个 IV,两段密文异或就能泄露出两段明文的异或结果。生产环境里 IV 要么随机生成随密文一起发,要么用“固定前缀 + 自增序号”的构造。

4.4 统一入口:一个 Encrypt 函数怎么接管五种模式

五种模式最后收敛到同一个Encrypt/Decrypt入口,靠一个switch分发。入口里集中做补齐和 IV 校验,各分支只管自己那段核心逻辑:

std::vector<uint8_t> CDesCore::Encrypt(const std::vector<uint8_t>& plain, const std::vector<uint8_t>& key, DesMode mode, std::vector<uint8_t>& iv) { // 1. 密钥与 IV 校验,不合格直接返回空,让界面层报错 if (key.size() != 8) return {}; if (mode != MODE_ECB && iv.size() != 8) return {}; // 2. 密钥调度只做一次,五种模式共用 uint8_t subKey[16][6]; KeySchedule(key.data(), subKey); // 3. 分组模式先补齐,流模式保持原长 std::vector<uint8_t> in = plain; if (mode == MODE_ECB || mode == MODE_CBC) PadPkcs7(in); std::vector<uint8_t> out(in.size()); switch (mode) { case MODE_ECB: /* 见 4.1 */ break; case MODE_CBC: /* 见 4.1 */ break; case MODE_CFB: /* 见 4.2 */ break; case MODE_OFB: /* 见 4.2 */ break; case MODE_CTR: /* 见 4.3 */ break; } return out; }

KeySchedule放在switch外面是刻意的:五种模式都只需要同一套子密钥,放在里面会让每个分支重复算 16 轮,加密大文件时性能差异很明显。Decrypt的结构完全对称,区别只在 ECB/CBC 分支要多一步UnpadPkcs7,以及 CBC/CFB 里对prev/feedback的更新顺序要跟着方向反过来。

5. 编译与运行避坑:字符集、补位、IV 三个高频翻车点

5.1 编译与字符集

现象:工程打开编译直接报Cannot open include file: 'afxwin.h',或者编译过了但链接时报一堆__imp_开头的未解析外部符号。

原因:前者是 Visual Studio 安装时没勾 MFC 组件;后者是 MFC 使用方式选了“在共享 DLL 中使用 MFC”,但目标机器上缺对应版本的运行库,或者工程引用了静态库版本的符号。还有一种隐蔽情况是项目从别的机器迁过来,Platform Toolset还是 v100,当前 VS 没装对应工具集。

解决:用 Visual Studio Installer 勾上“适用于最新 v142 生成工具的 C++ MFC”,工程属性里把平台工具集改成当前已安装的版本;发版给现场时改成“在静态库中使用 MFC”,虽然 exe 大了几 MB,但省掉一整轮“缺 dll”的远程支持。

现象:界面里中文密钥和明文加密后,对接方按 UTF-8 解出来全是问号或者乱码,但英文数字完全正常。

原因:MFC 工程默认 Unicode 字符集,CString内部是wchar_t,直接memcpy到std::vector<uint8_t>会把 UTF-16 的字节直接塞进去,一个中文字变成两个字节外加一个0x00,内核完全不知道这件事。

解决:所有CString到字节的转换统一走WideCharToMultiByte(CP_UTF8, ...),字节到CString走MultiByteToWideChar(CP_UTF8, ...)。在工程里加一个StringUtil.h,把这两个函数收口,禁止业务代码里出现裸的memcpy,这条规矩能挡掉后面 80% 的乱码问题。

5.2 密钥与填充的数据格式

现象:用户填 6 个字符的密钥,程序不报错照样加密成功,但对接方用同样的密钥解不开。

原因:内核对密钥长度的校验要么没做,要么做在KeySchedule里直接读越界——key.data()只有 6 个字节,BytesToU64读了 8 个,多出来的两个字节是未定义内容。这种 bug 在 Debug 下可能凑巧对上,Release 下换台机器就跑不通。

解决:在Encrypt入口最前面加key.size() != 8的硬判断,不合格直接返回空vector,界面层拿到空就弹框提示“密钥必须正好 8 字节”。不要做“不足补齐、超出截断”的隐式处理,隐式处理是跨系统对接中最大的坑源。

现象:解密出来的明文末尾多了几个奇怪的字节,比如0x05 0x05 0x05 0x05 0x05,界面显示出来像乱码。

原因:ECB/CBC 加密时做了 PKCS#7 补齐,解密端忘了去补齐,或者去补齐时读的最后一个字节被 UTF-8 转换破坏。补齐值是 1~8 之间的整数,如果解密函数在UnpadPkcs7之前先做了一次 UTF-8 转换,小于 0x80 的值会被当成单字节字符原样保留,刚好能读对;但一旦补齐值是 8,转成 UTF-8 会变成两个字节,截断长度就对不上了。

解决:UnpadPkcs7必须紧跟在DesBlock解密循环之后、任何字符串转换之前执行,而且校验pad <= 8 && pad <= data.size(),不合法就当成密钥错误处理。

5.3 模式使用与 IV 管理

现象:同一段明文用 CBC 加密两次,密文完全不一样,但两次都能正确解密,对接方以为程序有 bug。

原因:CBC 的 IV 是随机生成的,每次加密前重新填一次。这是正确行为,不是 bug,问题出在对接方预期上——他们以为密文应该是确定的。

解决:界面层在 CBC/CFB/OFB/CTR 模式下要显式展示“本次使用的 IV”,并且加密时把 IV 附在密文前面一起输出(常见做法是前 8 字节是 IV,后面是密文),解密时先切出前 8 字节当 IV 用。不要把 IV 藏在内核里生成,那是把黑匣子藏进黑匣子,出问题谁都查不到。

现象:用 CFB 加密一段 5 字节的明文,得到 8 字节密文,长度对不上,对接方截断后解密出来最后几个字节是垃圾。

原因:把 CFB 当成 ECB 处理了,进了PadPkcs7补齐。流模式的密文长度应该严格等于明文长度。

解决:在Encrypt入口根据mode决定是否补齐,只有 ECB/CBC 走PadPkcs7。同时输出里带一个“原始明文长度”字段,解密端按长度截取,不要依赖“密文长度一定是 8 的倍数”这个假设。

现象:同样的密钥和 IV,在本机加密、服务器解密,第一块能对上,从第二块开始全乱。

原因:大概率是 CBC 解密时prev更新用了明文而不是密文,或者 CFB 解密时DesBlock的enc参数传成了false。这两种错误都能通过“第一块正常、第二块开始错”这个特征定位。

解决:拿一组固定的测试向量,在单块解密和模式解密两个层面各测一遍。单块对了模式错,说明是链接逻辑的问题,重点看memcpy(prev, ...)里拷的到底是哪段内存。这个排查思路比盯着屏幕读代码快得多。

6. 二次开发:把这套内核接到你的业务系统里

6.1 换成别的算法或者加一层封装

这份工程里最值钱的部分不是 MFC 界面,而是DES.h/DES.cpp这份解耦干净的内核。二次开发第一步通常是把它从工程里“拔”出来:新建一个静态库工程CryptoLib,把这两个文件加进去,导出一个ICipher抽象接口,DES 作为其中一个实现,后面加 3DES、AES 都往这个接口后面塞。改完之后业务层只认识Encrypt(plain, key, mode, iv)一个签名,换算法不动调用方。

第二步是处理密钥长度问题。DES 的 8 字节密钥在今天已经不够用,常见的过渡方案是把三组 DES 密钥串起来做 3DES(EDE,加密-解密-加密),内核上只需要在DesBlock外面多套两层,子密钥表从 16 组扩到 48 组。如果业务对性能不敏感、只想快速上线,这是改动最小的路径。

第三步是把加解密搬到独立线程或者异步接口里。MFC 的按钮响应是跑在 UI 线程上的,明文超过几百 KB 就会卡界面,用户以为程序挂了。常见做法是用AfxBeginThread起一个工作线程,算完通过PostMessage把结果丢回主线程更新控件,界面在计算期间置灰按钮、显示一个进度条。

6.2 用已知答案测试验证

改完内核必须验证,验证的唯一可靠方式是已知答案测试(KAT)。DES 的公开测试向量非常好找,标准 ECB 一组:明文0123456789ABCDEF,密钥133457799BBCDFF1,密文85E813540F0AB405。CBC、CFB、OFB 也都有对应的标准向量,每加一种模式就补一组测试,跑通了再上业务。

我一般会建一个单独的CipherTest控制台工程,把内核源码原样拷进去,写完测试再编译成 exe 放 CI 里跑。这么做的好处是不受 MFC 运行库影响,任何一台装了 VS 的机器都能编。测试代码不用写复杂,一个main加几十行断言就够:

// 已知答案测试:一组 ECB 标准向量 int main() { const uint8_t key[8] = {0x13,0x34,0x57,0x79,0x9B,0xBC,0xDF,0xF1}; const uint8_t pt[8] = {0x01,0x23,0x45,0x67,0x89,0xAB,0xCD,0xEF}; uint8_t sk[16][6]; CDesCore::KeySchedule(key, sk); uint8_t ct[8]; CDesCore::DesBlock(pt, ct, sk, true); // 期望:85 E8 13 54 0F 0A B4 05 bool ok = (ct[0]==0x85 && ct[1]==0xE8 && ct[7]==0x05); return ok ? 0 : 1; // 非零退出码让 CI 直接红 }

我在这类项目上踩过一次难忘的教训:当时觉得加密功能已经“自测通过”(加密后再解密能还原),就跳过了标准向量验证,直接发给了对接方。结果他们那边的 Java 实现解出来全是乱码,来回排查了两天,最后发现是轮函数里 S 盒的行列号取反了。从那以后我每次改密码学相关代码,都强制先跑一遍标准向量,跑不过就算功能“看起来能用”也不提交。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询