简介:面向MFC开发者与密码学初学者的DES加密模式示例源码包,基于一重DES实现了ECB、CBC、CFB、OFB、CTR五种常用模式。资源配套友好MFC界面,代码中加入了详细注释,可清晰对照每种模式的分组链接差异,并方便进一步扩展为三重DES加密,适合在课程设计或安全项目演示中直接参考使用。压缩包共18个文件,包括4个头文件、3个C++程序文件、RC资源脚本、工程文件(dsw/dsp)、可执行程序、ReadMe说明等,整体仅57KB,结构紧凑便于携带查阅。目前已有1088人学习下载,口碑积累可见其教学价值。代码保留了可运行的exe,便于实际验证加解密流程;同时文件中已去除Debug目录,工程配置精简,更适合以源码阅读和二次开发为目的的场景。
五种DES加密模式的MFC实现:源码思路与踩坑实录
前段时间在维护一个老项目,遇到一个用MFC写的加密模块,客户要求把数据加密从ECB模式扩展到支持多种加密模式,最后把DES的五种经典模式——ECB、CBC、CFB、OFB、CTR——全部用MFC对话框工程实现了一遍,代码也整理成了可以复用的源码。这次实践下来最大的感受是:DES本身虽然老了,但亲手用C++在MFC里把这五种模式逐一实现,对理解分组密码的设计思路帮助特别大,而且这类源码在不少旧系统维护场景里依然有直接参考价值。如果你正在维护MFC老项目、要给现有系统加上加密功能,或者刚学完密码学理论想看看代码长什么样,这篇文章应该对你有用。
1. 从ECB到CTR:五种模式的设计动机与适用场景
很多刚接触DES的人以为调一个加解密函数就完事了,其实真正的复杂度全在“模式”上。DES本身只处理一个8字节分组,但实际数据动辄几百字节甚至几兆字节,怎么把这一个分组的能力扩展成对任意长度数据的加密方案,就是模式要解决的问题。五种模式分别对应了不同历史阶段对“安全”和“性能”的不同理解。
1.1 ECB和CBC:分组密码的基础玩法
ECB最简单,也最直观:把明文切成8字节一块,每块独立用DES加密,块与块之间没有任何关联。坏处是同样的明文块永远得到同样的密文块,这在很多场景下会泄漏信息。比如一张BMP位图,用ECB加密之后图片轮廓还能隐约看出来,因为大面积相同颜色的像素块加密后仍然相同。实际项目里如果要加密结构化数据、日志或者图片,ECB基本可以直接排除。
CBC就是冲着ECB的缺陷来的。每块明文在加密之前要先和上一块的密文做异或,第一个块则和一个初始向量(IV)做异或。这样即使两块明文完全相同,只要上文不同,密文也不同。CBC引入了“链”的概念,每个密文块依赖前面所有块,这带来一个特性:解密时如果某个密文块损坏,最多影响当前块和下一块的还原,之后的块不受影响。这种容错性在存储加密、网络传输加密里很关键。
1.2 CFB、OFB、CTR:把分组密码变成流密码
CFB、OFB、CTR这三种模式做的事情本质相同:用DES生成一串密钥流,再把密钥流和明文逐字节异或得到密文。区别只在密钥流怎么来。
CFB是密文反馈,把上一块密文作为输入去加密,生成当前块的密钥流。解密时也能从密文反推密钥流,所以加解密可以共用同一个DES加密函数,不需要实现解密算法。
OFB是输出反馈,把“加密上一块输出得到的结果”作为当前块的输入。密钥流一旦生成就只和初始状态有关,和明文密文都无关,所以可以预先算好一堆密钥流,等数据到了直接异或,吞吐量很高。但代价是如果传输过程出现数据丢失或错位,解密端没法自己恢复同步。
CTR是计数器模式,输入直接是一个从IV开始逐块递增的计数器,加密计数器得到密钥流再与明文异或。CTR最大的优势是每一块的密钥流生成互相独立,可以并行计算,这在现代CPU多核环境下性能优势非常明显,所以SSL/TLS等协议里大量使用。
1.3 五种模式的特性对比
从实践角度,我习惯用下面这个表来选型:
| 模式 | 密文是否分组相关 | 需要IV | 错误传播 | 并行性 | 典型场景 |
|---|---|---|---|---|---|
| ECB | 否 | 否 | 单块出错仅影响本块 | 可并行 | 单块数据加密,基本不建议用于多块数据 |
| CBC | 是 | 是 | 本块和下一块 | 解密可并行 | 文件加密、消息加密 |
| CFB | 是 | 是 | 本块和下一块 | 不可并行 | 流式通信场景 |
| OFB | 否 | 是 | 仅本块 | 不可并行 | 卫星通信、遥测数据 |
| CTR | 否 | 是 | 仅本块 | 加解密都可并行 | 高速通信、磁盘加密 |
做MFC项目时我一般推荐CBC,兼顾安全和实现复杂度,CTR作为性能优化备选。CFB和OFB更多是项目有历史依赖时才需要兼容。
2. MFC工程结构与DES核心类的设计
MFC环境写加密模块,最大的问题是很多人把逻辑全堆在对话框的按钮响应函数里,最终代码没法看也没法测。我的做法是先搭一个和界面无关的加密核心层,按钮只负责取输入、显示输出,验证和调试都在核心层完成。
2.1 工程结构怎么搭
在VS里创建一个MFC对话框应用,然后在解决方案里加三个层次的文件:
- DES核心算法文件:DesCore.h / DesCore.cpp,负责单块8字节的加解密,不涉及任何MFC类型。
- 模式封装文件:DesMode.h / DesMode.cpp,负责把五种模式实现为对DesCore的调用,处理分组、IV、填充逻辑。
- 界面调用层:在对话框类里调用DesMode的接口,完成CString和字节数组的转换。
这样分层的好处是核心逻辑可以脱离MFC单独测试。我甚至在调试时写了一个控制台小工程直接引用DesCore.cpp和DesMode.cpp,用标准测试向量验证正确后再回到MFC工程里联调,省掉了大量在MessageBox和断点之间来回折腾的时间。
2.2 单块DES加解密类的接口设计
DES的单块算法是有公开标准实现的,S盒、置换表、密钥编排这些内容网上能查到很多版本,但接口设计往往被忽略。我的DesCore对外只暴露两个静态函数:
class DesCore { public: // 单块加密:plain 8字节 -> cipher 8字节 static void EncryptBlock(const unsigned char plain[8], unsigned char cipher[8], const unsigned char key[8]); // 单块解密:cipher 8字节 -> plain 8字节 static void DecryptBlock(const unsigned char cipher[8], unsigned char plain[8], const unsigned char key[8]); };内部实现大致包括:初始置换IP、16轮Feistel迭代、轮密钥生成、逆初始置换。轮函数里用S盒做非线性替换,用P盒做置换扩散。这些过程教科书里都有,我不再贴完整代码,但有一点要提醒:实现时务必保留一份和标准测试向量一致的中间值调试工具,比如在每一轮结束时输出右边32位的值,和NIST文档核对。没有这个,一旦结果不对,你根本不知道是S盒写错还是密钥编排算错。
2.3 模式封装类的职责划分
DesMode类负责四种工作:分组、填充、IV管理和模式分发。接口设计如下:
class DesMode { public: // 加密:输入明文数据和密钥,按mode模式加密,输出密文 static bool Encrypt(const std::vector<unsigned char>& plain, const std::vector<unsigned char>& key, const std::vector<unsigned char>& iv, int mode, std::vector<unsigned char>& cipher); // 解密:输入密文数据和密钥,按mode模式解密,输出明文 static bool Decrypt(const std::vector<unsigned char>& cipher, const std::vector<unsigned char>& key, const std::vector<unsigned char>& iv, int mode, std::vector<unsigned char>& plain); enum Mode { ECB = 0, CBC = 1, CFB = 2, OFB = 3, CTR = 4 }; };为什么用std::vector而不用CString或者char数组?因为加密处理的是二进制数据,中间可能包含大量0x00,CString遇到\0就会截断,char数组又容易越界。std::vector能精确表达长度,后续转成CString或Base64只是界面层的事。
3. 五种模式的MFC实现:核心逻辑与关键代码
模式分发的核心是一个switch语句,每个case里做对应的分组循环。下面我按代码实际运行顺序,把五种模式的核心逻辑逐个拆开讲。
3.1 ECB模式实现
ECB实现最简单,注意处理最后不足8字节的分组:
size_t blocks = plain.size() / 8; size_t remain = plain.size() % 8; for (size_t i = 0; i < blocks; i++) { DesCore::EncryptBlock(plain.data() + i * 8, cipher.data() + i * 8, key.data()); } // 剩余不足8字节的部分需要填充 if (remain > 0) { unsigned char tmp[8] = {0}; memcpy(tmp, plain.data() + blocks * 8, remain); // PKCS7填充:填充值为缺失字节数 for (size_t j = remain; j < 8; j++) tmp[j] = (unsigned char)(8 - remain); DesCore::EncryptBlock(tmp, cipher.data() + blocks * 8, key.data()); }这里有个实现细节容易被忽略:如果明文长度恰好是8的整数倍,是否还要额外填充一个完整分组?按PKCS7的规则,答案是“要填充”。因为你必须让解密端能区分“原始数据就是8字节整”和“原始数据不足8字节后补了0”。如果不补,解密时无法确定最后一块是不是填充出来的。很多人写ECB只处理remain>0的情况,结果正好8字节的数据解密后尾部多出8个0x08,就是这个原因。
3.2 CBC模式实现
CBC的加密循环是这样的:
unsigned char prev[8]; memcpy(prev, iv.data(), 8); // 初始用外部传入的IV size_t totalBlocks = plain.size() % 8 == 0 ? plain.size() / 8 : plain.size() / 8 + 1; for (size_t i = 0; i < totalBlocks; i++) { unsigned char input[8] = {0}; if (i == totalBlocks - 1 && plain.size() % 8 != 0) { // 最后一块做PKCS7填充 size_t remain = plain.size() % 8; memcpy(input, plain.data() + i * 8, remain); for (size_t j = remain; j < 8; j++) input[j] = (unsigned char)(8 - remain); } else { memcpy(input, plain.data() + i * 8, 8); } // 核心:明文先和上一块密文(或IV)异或,再加密 for (size_t j = 0; j < 8; j++) { input[j] ^= prev[j]; } DesCore::EncryptBlock(input, cipher.data() + i * 8, key.data()); memcpy(prev, cipher.data() + i * 8, 8); // 当前密文作为下一轮的反馈 }CBC解密时方向是反的:先解密密文块,再和前一密文块异或得到明文。这也是很多新手容易搞混的地方——解密时“异或前一块密文”是在解密之后进行,不是之前。
3.3 CFB、OFB、CTR三种流模式实现对比
这三种模式代码结构相似,差异就在反馈源上,我把它们放在一起说:
// CFB:反馈上一块密文 unsigned char feedback[8]; memcpy(feedback, iv.data(), 8); for (size_t i = 0; i < totalBlocks; i++) { unsigned char keystream[8]; DesCore::EncryptBlock(feedback, keystream, key.data()); for (size_t j = 0; j < 8; j++) { cipher[i * 8 + j] = plain[i * 8 + j] ^ keystream[j]; } memcpy(feedback, cipher.data() + i * 8, 8); // 反馈密文 } // OFB:反馈加密后的前一轮输出 unsigned char feedback[8]; memcpy(feedback, iv.data(), 8); for (size_t i = 0; i < totalBlocks; i++) { unsigned char keystream[8]; DesCore::EncryptBlock(feedback, keystream, key.data()); for (size_t j = 0; j < 8; j++) { cipher[i * 8 + j] = plain[i * 8 + j] ^ keystream[j]; } memcpy(feedback, keystream, 8); // 注意不同点:反馈加密后的输出 } // CTR:反馈计数器自增 unsigned char counter[8]; memcpy(counter, iv.data(), 8); for (size_t i = 0; i < totalBlocks; i++) { unsigned char keystream[8]; DesCore::EncryptBlock(counter, keystream, key.data()); for (size_t j = 0; j < 8; j++) { cipher[i * 8 + j] = plain[i * 8 + j] ^ keystream[j]; } // 计数器按大端序递增 for (int j = 7; j >= 0; j--) { if (++counter[j] != 0) break; } }CTR的计数器递增看着简单,其实有个坑:如果直接对一个字节++,溢出的情况要考虑。大端序递增的意思是低字节在数组尾部,所以从后往前加,加到某个字节不为0就停止。还有一点:CTR模式不需要填充,因为密钥流是按字节异或的,最后不足8字节的明文只要生成部分密钥流异或即可,不需要补齐。这一点和ECB、CBC完全不同。
4. 填充、IV管理与字节序:实现中绕不开的细节
模式实现能跑通只是第一步,真正让代码能在实际项目里可靠运行,还得处理好填充、IV、字节序以及MFC字符串转换这些细节。我在调试过程中至少有一半时间耗在这些问题上。
4.1 分组不满8字节怎么办
上面已经提到PKCS7填充,这里补充一个实际使用中的决策点:填充动作放哪一层?
我见过有些实现把填充放在DesMode外部,调用方先自己填充再调用。我建议放在DesMode内部处理,统一用PKCS7,这样调用方不用关心数据长度。但要注意解密时必须在返回明文前先验证最后一块的填充值合法性,防止无效填充数据被当成明文返回。
网上也有用PKCS5的说法,对8字节分组的DES来说PKCS5和PKCS7的效果完全一样,不用纠结叫法。
4.2 IV的生成与重置规则
CBC、CFB、OFB、CTR都需要IV。实际项目中,IV的生成方式直接影响安全性,如果你对每个会话都复用同一个IV,那加密强度会大打折扣,攻击者可以通过对比密文推测明文关系。
MFC项目里我常用的方案是:用系统时间加上一个随机种子生成IV,把IV直接拼在密文头部一起存储或传输。这样解密端天然知道用的什么IV,不需要额外通道传递。但要注意一点:IV本身不要求保密,只要求不可预测且不重复,所以不用对IV做特殊保护。
4.3 CString与字节数组的转换陷阱
这是MFC环境特有的坑。MFC默认工程可能是Unicode编码,CString内部是wchar_t数组,而DES处理的是unsigned char。如果直接把CString的LPCTSTR指针强转成unsigned char*去加密,结果完全不是你想要的那份数据的密文,因为每个字符占了两个字节。
我封装了两个辅助函数:
// CStringA是ANSI版本字符串,按单字节处理 std::vector<unsigned char> StringToBytes(const CString& str) { CStringA ansiStr(str); // Unicode转ANSI std::vector<unsigned char> bytes(ansiStr.GetLength()); memcpy(bytes.data(), ansiStr.GetString(), ansiStr.GetLength()); return bytes; } CString BytesToHexString(const std::vector<unsigned char>& bytes) { CString result; for (size_t i = 0; i < bytes.size(); i++) { result.AppendFormat(_T("%02X"), bytes[i]); } return result; }加密前统一把输入的CString转成ANSI字节数组,加密后把密文转成十六进制字符串显示。之所以转十六进制而不是直接转CString,是因为密文是二进制数据,可能包含不可见字符,直接转成字符串会出现截断或显示乱码。十六进制既能完整保存密文,又方便人工比对结果。
5. MFC界面联调中的典型坑与排查经验
在MFC对话框里把代码串起来之后,我遇到了一些和界面相关的问题。这些问题单独看都不难,但组合在一起很容易让人怀疑算法实现是不是错了,这里列出来算是给后来者排雷。
5.1 界面卡死与数据错乱
第一个坑是大文件加密时界面卡死。DES本身速度并不快,在Debug模式下加密一个几十MB的文件需要几秒甚至更久,而我在最开始的版本里直接在按钮响应函数里同步处理,界面完全无响应,看起来像程序死了。解决方法是把加密放到工作线程里,或者对大文件分块处理并在循环里调用一次消息泵,二选一。这个既是教训也是常识,但还是有人踩。
另一个坑是Edit Control控件的换行符。在MFC的Edit控件里显示多行密文时,\r\n会被自动处理,但如果你把十六进制密文按每行64个字符分段显示再复制出来,复制的内容可能带有额外的\r,导致拿去解密时数据长度不对。稳妥的办法是界面上一律用无换行的连续十六进制字符串,需要分段展示时再单独写一个显示逻辑,不要直接操作密文字节。
5.2 调试技巧:拿标准测试向量对拍
排错时最有效的工具就是标准测试向量。NIST的SP 800-38A文档里提供了DES三个模式的示例,网上也很容易找到覆盖五种模式的测试数据。我的做法是在DesMode里加一个自检函数:
bool DesMode::SelfTest() { // 用标准向量校验CBC std::vector<unsigned char> key = {0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF}; std::vector<unsigned char> iv = {0x12, 0x34, 0x56, 0x78, 0x90, 0xAB, 0xCD, 0xEF}; std::vector<unsigned char> plain = {0x4E, 0x6F, 0x77, 0x20, 0x69, 0x73, 0x20, 0x74}; // 期望密文...(查标准文档) ... }在程序启动或者调试版本里调用SelfTest,确认基础实现正确后再去做MFC界面联调,能分离“算法写错”和“界面转换写错”两类问题。我调试的时候基本上先用这个自检把DesCore和DesMode排除掉,然后再查CString转换的代码。
5.3 关于DES强度与模式选型的一点看法
最后聊一点题外话。从密码学发展史的角度看,DES的56位密钥强度在硬件算力飞速发展的今天已经被证实可以在可接受时间内被暴力破解,新项目直接用DES确实不合适。但在MFC老项目维护、嵌入式系统兼容、以及教学研究场景里,理解DES五种模式的实现思路仍然非常值得,因为这些模式的设计思想在AES上完全复用,CBC、CTR这些词在今天的工程里依然天天遇到。
我在实际维护中更多是把这套实现作为一个学习跳板:先在MFC里跑通DES的五种模式,再把DesCore替换成AES的核心实现,模式封装层几乎一行不用改。这也是为什么我愿意把模式层和算法层分那么清楚——换算法只换一层,模式逻辑一次写好到处复用。你可以沿着这个思路,把加密模块继续扩展成支持AES,或者在界面层增加文件加解密、Base64编码等功能,都是一个套路。
如果你在MFC下写完这套代码,建议保留一份命令行测试工具,别只依赖界面。加密这种东西,出错往往是静悄悄的,没有明文比对,光看界面上的密文很难发现问题。这个习惯帮我省下的时间,远超写测试工具本身花费的时间。
本文还有配套的精品资源,点击获取