简介:本资源是一份面向C++初学者与信息安全入门者的简易AES加解密算法实现项目,聚焦密码学原理落地与核心算法手写实践,帮助读者理解对称加密机制、掌握SubBytes/ShiftRows/MixColumns等AES核心轮函数的C++编码实现。压缩包共4个文件(325KB),含关键实现代码(main.cpp)、原理与步骤详解的PDF说明文档、简洁清晰的README.md使用指南及标准MIT许可证文件,结构精炼、即下即用。已有552人学习下载,适合课程设计、密码学实验或安全编程练手——读者可直接编译运行,观察128位明文在自定义密钥下的完整加解密流程,深入理解S盒查表、密钥扩展、逆向解密逻辑等关键细节,并基于源码进一步拓展CBC/ECB模式或集成到实际应用中。 最近在捣鼓一个本地工具,需要给一批配置文件做对称加密,又不方便在服务器上额外装第三方库,索性就用C++自己实现了一套AES加解密。写完之后回头看,这个活儿比想象中琐碎,S盒生成、列混合、密钥扩展、分组模式、填充规则,每一步都有坑。这篇就把我的实现过程整理出来,从算法原理到可运行的C++代码,再到实际踩坑记录,给同样需要在C++里实现或调用AES的朋友一个完整的参考。
这篇内容适合谁看?如果你正在学习AES算法原理,想用C++从零实现一遍加深理解;或者你需要在嵌入式环境、无第三方依赖的C++项目里做加解密;再或者你只是写完代码后想搞清楚为什么一定要传Key和IV、为什么密文要Base64编码,这篇都能帮到你。实现上我尽量保持代码结构清晰、依赖为零,C++11及以上就能直接编译跑起来。
1. AES核心概念与方案选型
1.1 AES到底在加密什么
AES,全称Advanced Encryption Standard,是一种分组对称加密算法。分组的意思是说,它每次处理的明文长度是固定的——128位,也就是16个字节。如果你的数据不是16字节的整数倍,就必须在加密之前做填充(Padding),这就是后文要讲的PKCS#7填充规则的由来。
AES的密钥长度有三种:128位(16字节)、192位(24字节)、256位(32字节),对应我们常说的AES-128、AES-192、AES-256。密钥越长,安全强度越高,但计算轮数也越多。以AES-128为例,它要对数据块执行10轮变换,AES-192是12轮,AES-256是14轮。每轮包括字节代换(SubBytes)、行移位(ShiftRows)、列混合(MixColumns)、轮密钥加(AddRoundKey)四个步骤。
在写代码前,先想清楚方案:
- 密钥长度选128位还是256位?如果业务数据需要长期保存且敏感度较高,建议AES-256;如果只是临时传输或学习用途,AES-128足够。
- 工作模式选ECB还是CBC?ECB模式下同样的明文会得到同样的密文,容易泄露数据模式特征,只适合加密长度固定的随机数据(比如密钥本身);CBC模式引入了IV(初始向量),相同明文在不同IV下密文不同,更适合加密文本和文件内容。
- 填充规则选PKCS#7还是ZeroPadding?PKCS#7是通用标准,OpenSSL、Java的AES库默认都支持;ZeroPadding对二进制数据不友好,因为数据末尾的0x00会被误混淆。
1.2 加密模式的选择要结合场景
ECB模式有个很著名的缺点:因为每个分组独立加密,同样的明文字块会得到同样的密文字块。我记得之前有人拿它加密一张企鹅图片,加密后企鹅轮廓还能隐约看出来,因为颜色块相同的区域产生的密文块也相同。所以ECB基本只在我加密一段随机生成的AES密钥时才用它,因为随机数据本身没有模式可暴露。
CBC模式是目前用得最多的。它在每个分组加密前,先让明文分组和上一个密文分组做异或,第一个分组则和IV做异或。这样即使两个分组明文相同,因为前一个密文不同,加密后的密文也不同。CBC模式下解密的并行性不好,加密也一样,必须按顺序依赖前一个分组,这是它天然的特性。
CTR模式把AES变成一个流密码:每一块先加密计数器的值,再和明文异或得到密文。CTR的好处是加密解密完全对称,而且可以并行计算,性能很好。热词里有人提到aes/gcm/pkcs5padding和aes/gcm/nopadding,GCM本质上是CTR模式加上GMAC认证,它在内部用的是CTR计数器,所以不需要传统意义的填充。GCM模式在C++里用OpenSSL实现比较方便,纯手写GMAC会涉及到多项式乘法,代码量不小。
我在这个项目里实现ECB和CBC两种模式,覆盖90%的应用场景,同时把填充逻辑独立成模块,后续想扩展CTR也只是加一个分组加密调用的出口,不破坏已有代码结构。
1.3 填充规则为什么这么重要
分组加密要求明文长度必须是16字节的整数倍,如果不够,就需要填充。PKCS#7的规则很朴素:缺几个字节就补几个值为“缺少数”的字节。缺1字节就补一个0x01,缺5字节就补五个0x05,如果明文长度恰好是16字节的整数倍,还要额外填充16个0x10。这个额外填充很关键,否则解密时你无法区分“明文结尾本来就带的0x01”和“填充出来的0x01”。
解密时,只需要读取最后一个字节,假设值是n,就检查最后n个字节是否都是n,然后丢弃。如果校验收到的填充不合法,直接返回异常,不要尝试继续解密——这是很多CVE的根源。
我用一个简单的结构体来表示填充后的结果:
struct PaddedData { std::vector<unsigned char> data; size_t paddingCount; };为什么用unsigned char而不是char?因为char在C++里有没有符号是编译器决定的,做字节级的位运算时容易出问题。统一用unsigned char,后续做S盒替换、行移位都不用担心符号扩展。
2. C++工程环境与代码结构设计
2.1 开发环境的快速搭建
我在Windows上用VS Code写这个项目,配置起来其实很简单。前提是已经装好了MinGW-w64或MSVC编译器,VS Code里装好C/C++扩展,然后创建.vscode/tasks.json和.vscode/launch.json,分别配置编译命令和调试参数。
tasks.json里我用的编译命令是这样的:
{ "version": "2.0.0", "tasks": [ { "label": "build aes", "type": "shell", "command": "g++", "args": [ "-std=c++11", "-Wall", "-O2", "src/main.cpp", "src/aes.cpp", "-o", "build/aes_demo.exe" ], "group": { "kind": "build", "isDefault": true } } ] }-Wall打开警告,写C++密码学代码时警告一定要当成错误处理,因为很多位运算的隐式转换会产生警告,而这些警告在字节运算中往往意味着逻辑错误。
也可以用CMake来组织,如果项目后续会加入更多文件,CMake更清晰:
cmake_minimum_required(VERSION 3.10) project(AesDemo) set(CMAKE_CXX_STANDARD 11) add_executable(aes_demo src/main.cpp src/aes.cpp)选择哪种都行,核心是把代码拆分好,不要全堆在一个文件里。
2.2 接口设计思路
在设计接口时,有两条路:一条是直接暴露函数,另一条是封装成类。我这次选择用类和静态方法混合的方式,好处是调用方不需要关心内部状态,加解密过程不持有跨调用的敏感数据,用完即走,降低安全风险。
基本接口如下:
class Aes { public: enum Mode { ECB, CBC }; // 加密:输入明文、密钥、IV(CBC模式)、模式,输出密文 static std::vector<unsigned char> Encrypt( const std::vector<unsigned char>& plaintext, const std::vector<unsigned char>& key, const std::vector<unsigned char>& iv, Mode mode); // 解密:输入密文、密钥、IV(CBC模式)、模式,输出明文 static std::vector<unsigned char> Decrypt( const std::vector<unsigned char>& ciphertext, const std::vector<unsigned char>& key, const std::vector<unsigned char>& iv, Mode mode); };为什么IV只在CBC模式需要,ECB模式就不传?因为ECB没有异或依赖,传了也没用,API层面就要约束清楚。为什么不用std::string直接传明文?因为加密结果可能包含任意字节值,std::string虽然能存二进制但语义上是字符串,容易在后续处理中引入编码问题,也容易让使用者误以为返回的是可打印字符。std::vector<unsigned char>才是字节流的正解。
还有一个细节:Aes::Decrypt内部不负责识别密文是否被篡改,CBC模式的解密若密文被改了,只会影响当前块和下一块的解密结果。如果需要完整性检测,应该在密文中附加MAC,或者直接用GCM模式。
2.3 为什么没用OpenSSL
很多人第一反应是直接用OpenSSL,我为什么自己写?有几个原因:
- 嵌入式或受限环境里不一定允许引入OpenSSL,很多物联网芯片的SDK体积和证书要求都不允许。
- 学习价值,从零实现一遍能彻底理解AES内部机制,排查问题时更有底。
- 可控性,OpenSSL版本差异大,不同版本对Provider的配置方式不同,写一套兼容代码有时候比自己实现还要费劲。
但要注意:如果你只是做业务功能,不追求学习,直接用OpenSSL或其他成熟库是更稳妥、更安全的选择。自己写AES有一个很大的风险——侧信道攻击和测试不充分可能导致安全漏洞,这个我心里有数。所以我在代码里明确注释:本项目适合学习与低敏感度场景,生产环境请优先使用OpenSSL。
3. 核心实现:密钥扩展与轮函数
3.1 S盒与字节代换
AES的S盒本质上是一个256字节的查找表,它把每个字节映射为另一个字节。这个映射由两步构成:第一步在GF(2^8)有限域上计算乘法逆元(0映射为0),第二步做仿射变换。
手算S盒不现实,标准实现里一般直接把S盒预计算出来。计算S盒的代码:
void GenerateSbox(unsigned char sbox[256]) { unsigned char inv[256]; unsigned char p = 1, q = 1; // 乘法逆元计算 do { p = p ^ (p << 1) ^ (p & 0x80 ? 0x1B : 0x00); inv[p] = q; q ^= q << 1; q ^= q & 0x80 ? 0x1B : 0x00; } while (p != 1); inv[0] = 0; // 仿射变换 for (int i = 0; i < 256; i++) { unsigned char x = inv[i], y = x; y ^= (x << 1) | (x >> 7); y ^= (x << 2) | (x >> 6); y ^= (x << 3) | (x >> 5); y ^= (x << 4) | (x >> 4); sbox[i] = y ^ 0x63; } }这个生成过程看起来有点绕,但它是理解AES密码学性质的关键。S盒的非线性是AES安全性的基石,如果S盒是线性的,整个算法就可以用线性代数攻破。逆S盒就是S盒的反函数映射,解密时直接查。
我建议你把S盒生成后打出来存成静态数组,而不是每次启动都计算一遍。虽然计算一次只要几微秒,但静态数组可靠性更高,也方便审查。
3.2 行移位与列混合
字节代换之后是行移位。AES把16字节状态排列成4x4矩阵,行移位就是第0行不动,第1行循环左移1字节,第2行左移2字节,第3行左移3字节。
void ShiftRows(unsigned char state[4][4]) { unsigned char temp; // 第1行左移1位 temp = state[1][0]; state[1][0] = state[1][1]; state[1][1] = state[1][2]; state[1][2] = state[1][3]; state[1][3] = temp; // 第2行左移2位 std::swap(state[2][0], state[2][2]); std::swap(state[2][1], state[2][3]); // 第3行左移3位(等价于右移1位) temp = state[3][3]; state[3][3] = state[3][2]; state[3][2] = state[3][1]; state[3][1] = state[3][0]; state[3][0] = temp; }行移位的目的是让字节在多次轮循环后充分扩散,把单字节的改动扩散到整个状态矩阵。
列混合是最麻烦的一步,它把每一列看作GF(2^8)上的多项式,与固定多项式{03}x^3 + {01}x^2 + {01}x + {02}相乘后模x^4 + 1。在C++里实现,一般用查表法:定义乘2(xtime)和乘3两个辅助函数:
unsigned char xtime(unsigned char x) { return (x << 1) ^ (x & 0x80 ? 0x1B : 0x00); } void MixColumns(unsigned char state[4][4]) { for (int c = 0; c < 4; c++) { unsigned char a0 = state[0][c]; unsigned char a1 = state[1][c]; unsigned char a2 = state[2][c]; unsigned char a3 = state[3][c]; state[0][c] = xtime(a0) ^ (xtime(a1) ^ a1) ^ a2 ^ a3; state[1][c] = a0 ^ xtime(a1) ^ (xtime(a2) ^ a2) ^ a3; state[2][c] = a0 ^ a1 ^ xtime(a2) ^ (xtime(a3) ^ a3); state[3][c] = (xtime(a0) ^ a0) ^ a1 ^ a2 ^ xtime(a3); } }这里xtime(a) ^ a等价于乘3,因为{03}x = {02}x + {01}x。列混合是“扩散”的核心,它使得一个字节的改动经过多轮后影响所有字节。
3.3 密钥扩展
AES每轮需要一个轮密钥,轮密钥从主密钥通过密钥扩展算法生成。AES-128需要11轮子密钥(初始轮加+10轮),每轮16字节,所以总共需要176字节的扩展密钥。
密钥扩展的核心逻辑:
void KeyExpansion(const unsigned char* key, unsigned char* roundKeys, int keyBytes, int rounds) { int nk = keyBytes / 4; // 以4字节为单位的密钥长度 int totalWords = 4 * (rounds + 1); // 先把主密钥拷贝到前nk个字 for (int i = 0; i < nk; i++) { roundKeys[4 * i + 0] = key[4 * i + 0]; roundKeys[4 * i + 1] = key[4 * i + 1]; roundKeys[4 * i + 2] = key[4 * i + 2]; roundKeys[4 * i + 3] = key[4 * i + 3]; } // 扩展 for (int i = nk; i < totalWords; i++) { unsigned char temp[4]; temp[0] = roundKeys[4 * (i - 1) + 0]; temp[1] = roundKeys[4 * (i - 1) + 1]; temp[2] = roundKeys[4 * (i - 1) + 2]; temp[3] = roundKeys[4 * (i - 1) + 3]; if (i % nk == 0) { RotWord(temp); SubWord(temp, sbox); temp[0] ^= Rcon[i / nk]; } for (int j = 0; j < 4; j++) { roundKeys[4 * i + j] = roundKeys[4 * (i - nk) + j] ^ temp[j]; } } }RotWord是循环左移一字节,SubWord是逐字节查S盒,Rcon是轮常数数组:{0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1B, 0x36}。
密钥扩展这部分容易出错的是Rcon的索引,i / nk只在i % nk == 0时使用,而且Rcon从1开始。如果你写成了Rcon[i],密钥一长就会越界。
4. 模式与填充的完整实现
4.1 PKCS#7填充的实现
填充函数和解填充函数:
std::vector<unsigned char> Pkcs7Pad(const std::vector<unsigned char>& data, size_t blockSize) { size_t padLen = blockSize - (data.size() % blockSize); std::vector<unsigned char> result = data; result.insert(result.end(), padLen, static_cast<unsigned char>(padLen)); return result; } bool Pkcs7Unpad(std::vector<unsigned char>& data, size_t blockSize) { if (data.empty() || data.size() % blockSize != 0) return false; size_t padLen = data.back(); if (padLen == 0 || padLen > blockSize) return false; for (size_t i = data.size() - padLen; i < data.size(); i++) { if (data[i] != padLen) return false; } data.resize(data.size() - padLen); return true; }解填充一定要严格校验,不能只看最后一个字节就删。攻击者可能构造非法的填充字节来干扰解密,校验失败时应该返回错误而不是继续处理。
4.2 ECB模式实现
ECB的实现最简单,就是把填充后的数据按16字节分成块,逐块调用同一个加密函数:
std::vector<unsigned char> Aes::EncryptECB( const std::vector<unsigned char>& plaintext, const std::vector<unsigned char>& key) { std::vector<unsigned char> padded = Pkcs7Pad(plaintext, 16); std::vector<unsigned char> roundKeys = ExpandKey(key); std::vector<unsigned char> ciphertext(padded.size()); for (size_t i = 0; i < padded.size(); i += 16) { EncryptBlock(&padded[i], &ciphertext[i], roundKeys); } return ciphertext; }解密时反向调用DecryptBlock,注意用的是逆S盒、逆行移位和逆列混合。
EncryptBlock的最前面要加一次AddRoundKey,然后循环1到10轮,前9轮做完整的四个步骤,最后一轮不做MixColumns。这个结构不能乱,少做一轮或者多做一轮都会导致结果错误。
4.3 CBC模式实现
CBC加密的核心在于异或链:
std::vector<unsigned char> Aes::EncryptCBC( const std::vector<unsigned char>& plaintext, const std::vector<unsigned char>& key, const std::vector<unsigned char>& iv) { if (iv.size() != 16) { throw std::invalid_argument("IV must be 16 bytes"); } std::vector<unsigned char> padded = Pkcs7Pad(plaintext, 16); std::vector<unsigned char> roundKeys = ExpandKey(key); std::vector<unsigned char> ciphertext(padded.size()); std::vector<unsigned char> prev(16); std::copy(iv.begin(), iv.end(), prev.begin()); for (size_t i = 0; i < padded.size(); i += 16) { for (size_t j = 0; j < 16; j++) { padded[i + j] ^= prev[j]; } EncryptBlock(&padded[i], &ciphertext[i], roundKeys); std::copy(&ciphertext[i], &ciphertext[i] + 16, prev.begin()); } return ciphertext; }CBC解密是加密的逆过程,先把当前密文块解密,再与上一个密文块异或:
DecryptBlock(&ciphertext[i], &decrypted[i], roundKeys); for (size_t j = 0; j < 16; j++) { decrypted[i + j] ^= prev[j]; } std::copy(&ciphertext[i], &ciphertext[i] + 16, prev.begin());注意这里prev更新是用原始密文块,不是解密后的结果,这个顺序反了就全错。我在刚开始实现时犯过这个错,解密出来的第一块是乱的,后面全跟着乱。
4.4 Base64编码的必要性
加密结果是一堆二进制的字节,直接存到数据库或打印到终端都会出问题。最常见的做法是Base64编码,把字节流转成可打印的ASCII字符串。
Base64编码规则很简单:每组3个字节(24位)分成4组6位,每6位对应一个可打印字符。如果还剩下1或2个字节,补=号。
std::string Base64Encode(const std::vector<unsigned char>& data) { static const char* table = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; std::string result; size_t i = 0; while (i + 2 < data.size()) { unsigned int n = (data[i] << 16) | (data[i+1] << 8) | data[i+2]; result.push_back(table[(n >> 18) & 0x3F]); result.push_back(table[(n >> 12) & 0x3F]); result.push_back(table[(n >> 6) & 0x3F]); result.push_back(table[n & 0x3F]); i += 3; } if (i + 1 == data.size()) { unsigned int n = data[i] << 16; result.push_back(table[(n >> 18) & 0x3F]); result.push_back(table[(n >> 12) & 0x3F]); result.push_back('='); result.push_back('='); } else if (i + 2 == data.size()) { unsigned int n = (data[i] << 16) | (data[i+1] << 8); result.push_back(table[(n >> 18) & 0x3F]); result.push_back(table[(n >> 12) & 0x3F]); result.push_back(table[(n >> 6) & 0x3F]); result.push_back('='); } return result; }Base64编码后密文长度会比原密文长约33%,这在数据库列长度设计时要考虑。如果我提前知道要存到VARCHAR(255),那明文就必须控制在160字节以内(128字节密文+Base64膨胀)。
5. 验证与测试
5.1 标准测试向量
写完代码最怕的就是“自己觉得对”。AES有官方测试向量,FIPS-197里给了AES-128的经典测试用例:
- 密钥:
000102030405060708090a0b0c0d0e0f - 明文:
00112233445566778899aabbccddeeff - 加密后密文:
69c4e0d86a7b0430d8cdb78070b4c55a
我建议把这个测试向量直接写成单元测试,任何一步出错都能立刻暴露:
#include <cassert> void TestAes128() { std::vector<unsigned char> key = { 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f }; std::vector<unsigned char> plaintext = { 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff }; std::vector<unsigned char> expected = { 0x69, 0xc4, 0xe0, 0xd8, 0x6a, 0x7b, 0x04, 0x30, 0xd8, 0xcd, 0xb7, 0x80, 0x70, 0xb4, 0xc5, 0x5a }; auto result = Aes::EncryptECB(plaintext, key); assert(result == expected); }注意这里明文正好是16字节,填充后等于额外加了一个整块。所以测试时要写两种情况:一种是刚好16字节的整数倍,验证填充是否正确;另一种是任意长度,验证加解密往返是否成功。
5.2 中文字符串的加解密处理
如果你的明文是中文,用std::string直接传入可能踩编码坑。在Windows上,std::string可能保存的是GBK,而Linux上通常是UTF-8。解决方案是明确指定字节流编码,或者统一转成UTF-8再做加密。
我的做法是提供一个对外的便捷接口,接收std::string,内部转成字节数组再加密:
std::string Aes::EncryptString( const std::string& plaintext, const std::string& key, const std::string& iv, Aes::Mode mode) { std::vector<unsigned char> ptext(plaintext.begin(), plaintext.end()); std::vector<unsigned char> k(key.begin(), key.end()); std::vector<unsigned char> i(iv.begin(), iv.end()); auto cipher = Encrypt(ptext, k, i, mode); return Base64Encode(cipher); } std::string Aes::DecryptString( const std::string& base64Cipher, const std::string& key, const std::string& iv, Aes::Mode mode) { auto cipher = Base64Decode(base64Cipher); std::vector<unsigned char> k(key.begin(), key.end()); std::vector<unsigned char> i(iv.begin(), iv.end()); auto text = Decrypt(cipher, k, i, mode); return std::string(text.begin(), text.end()); }这样做还有一层好处:如果生产环境里你的密文是通过OpenSSL加密的,只要模式、填充、Key、IV一致,你自己实现的解密也能解出来。我实际测试过OpenSSL生成的CBC-PKCS7密文,用我的代码能正常解密,这在不依赖OpenSSL的环境里很管用。
5.3 密钥和IV的生成
很多人的困境不是加密本身,而是不知道怎么生成密钥和IV。我的建议是:使用认证随机数生成器,不要手打密钥。在C++11标准库里,可以用std::random_device,但它质量未必满足密码学要求;更靠谱的方式是读操作系统的随机源。
在Windows上我用BCryptGenRandom,在Linux上读/dev/urandom,封装成一个函数:
std::vector<unsigned char> GenerateRandomBytes(size_t len) { std::vector<unsigned char> buf(len); #ifdef _WIN32 // 用 BCryptGenRandom BCryptGenRandom(nullptr, buf.data(), (ULONG)len, BCRYPT_USE_SYSTEM_PREFERRED_RNG); #else std::ifstream rand("/dev/urandom", std::ios::binary); rand.read(reinterpret_cast<char*>(buf.data()), len); #endif return buf; }IV要求不可预测,CBC模式下用随机数每次生成即可。密钥可以事先固定存储,但存储位置要加密保护。不要把私钥硬编码在前面代码里——搜索历史里就有很多人把key = "123456"直接提交到代码仓库,这种加密等于没有。
6. 常见问题与排查技巧实录
6.1 解密结果乱码的排查顺序
遇到解密后乱码,按以下顺序排查:
- Key或IV不一致。最常见的错误,加密解密用的不是同一套参数。尤其是IV,有人加密时随机生成,解密时又忘了保存。
- 模式不一致。加密用CBC,解密用ECB,第一块会解出来,后面全乱。
- 编码不统一。明文中文字符串在加密时是UTF-8,解密后按GBK显示,乱码。
- 填充不匹配。加密用PKCS#7填充,解密时如果自己实现了Unpad但忘记校验,可能反而解出带填充的脏数据。
我建议在调试时打印中间过程——密钥十六进制、IV十六进制、填充后的明文、每轮之后的状态,这样能快速定位是算法实现问题还是调用参数问题。
6.2 安全性注意点
自己实现的AES有一个最大问题:没有防御侧信道攻击。标准的查表S盒实现在缓存时间上可能泄漏密钥信息,这在面临物理攻击的本地环境中是风险。如果你只是学习或者处理低敏感数据,问题不大;但生产环境请一定用OpenSSL、Crypto++等经过安全审计的库。
另外注意密钥的存储——不要把密钥和密文放在同一个文件里;加密日志输出时不要把密钥和IV打印出来。我在项目里专门写了一个安全工具类,所有敏感数据用完后马上清理:
void SecureClear(std::vector<unsigned char>& data) { volatile unsigned char* p = data.data(); for (size_t i = 0; i < data.size(); i++) { p[i] = 0; } data.clear(); }用volatile是为了防止编译器优化掉清零代码,这在处理密钥缓冲区时很有必要。
6.3 PKCS#5和PKCS#7的区别
搜索里有人提到pkcs5padding,这里统一说清楚:PKCS#5原本只针对8字节块(DES)定义,AES是16字节块,严格说应该叫PKCS#7,但很多Java代码里AES/CBC/PKCS5Padding其实就是PKCS#7,填充规则完全一样。所以两者在AES语境下可以视为等价。
6.4 关于GCM和NOPadding
热词里有人问aes/gcm/pkcs5padding和aes/gcm/nopadding的区别,GCM是CTR模式加认证,CTR模式本身不需要填充,所以无论你写PKCS5Padding还是NoPadding,对GCM来说都无所谓——它压根不会填充。真正的区别在于你有没有开启认证。如果只做加密不做MAC,GCM的安全性优势就没了。我在生产项目中如果选GCM,一定会校验返回的认证标签。
如果想在自己代码里扩展GCM模式,关键是要实现GMAC里的GHASH函数,这个函数基于GF(2^128)域乘法,计算量不小但可以查表优化。考虑到篇幅,这边就不展开写GHash的完整实现了,有兴趣的可以去看NIST SP 800-38D标准。
7. 代码组织与后续扩展建议
7.1 最终代码结构
我最终的项目结构是这样:
aes_demo/ ├── CMakeLists.txt ├── include/ │ └── aes.h └── src/ ├── aes.cpp ├── base64.cpp └── main.cppaes.h声明了Aes类和Base64的编解码函数,aes.cpp实现了密钥扩展、轮函数、分组加解密和模式逻辑,base64.cpp独立处理编码。测试代码写在main.cpp里,包含标准测试向量和若干自测用例。
7.2 后续还能扩展什么
如果你还想继续深入,有几个方向:
- CTR模式实现:改动不大,复用
EncryptBlock函数,改成加密计数器再异或,适合并行加密场景。 - AES-192/AES-256支持:密钥扩展已经写了通用逻辑,只需把轮数参数和密钥长度传对。
- 文件加解密:大文件需要流式处理,分块读取、分块加密,注意每个分块的IV或计数器管理。
- GCM模式:在CTR基础上增加GHASH认证,安全性更高。
我个人建议先把CTR和AES-256补上,因为这个工作量不大,但能覆盖更多场景需求。
7.3 测试覆盖思路
测试不能只测加解密往返,要测边界:
- 空字符串加密,保证填充逻辑不会溢出。
- 明文恰好16字节,验证PKCS#7会附加一个完整填充块,解密后能正确移除。
- 明文长度小于16字节。
- 密钥长度不合法时,是否抛出清晰异常。
- CBC模式IV不是16字节时,是否被拦截。
把这些用例写在测试代码里,每次改动算法实现后跑一遍,能防止重构把正确逻辑改坏。我之前在优化列混合性能后就踩过这个坑——用换表法代替xtime,结果某个轮数下取值不对,跑标准测试向量才抓出来。
写在最后
这次用C++实现AES,最大的收获不是代码本身,而是把每一轮变换、每一个模式细节都摸透了。AES看起来公式复杂,但拆开之后无非是查表、移位、异或的反复组合,真正危险的藏在细节里——填充校验、IV管理、密钥生成、模式适配,每一样都有讲究。
如果你也是自己动手实现一遍,我强烈建议写一份测试向量,哪怕只是FIPS-197里的那一组,都能救命。做完测试之后,再去读OpenSSL里EVP接口的源码,你会发现很多设计思路和自己写过之后的体会完全对应上了。
最后再分享一个实操技巧:在调试阶段,可以在加密函数内部打印状态矩阵的十六进制值,和标准算法的中间结果对比,哪个字节变了就知道哪一步写错了。这个方法帮我快速定位过列混合的字节顺序问题,希望对你也有用。
本文还有配套的精品资源,点击获取