ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

C++17实现纯手写AES算法:从GF(2^8)到ECB/CBC分组模式

C++17实现纯手写AES算法:从GF(2^8)到ECB/CBC分组模式 简介本资源是一份面向C初学者与信息安全入门者的简易AES加解密算法实现项目聚焦密码学核心原理与工程落地结合解决学习者在理解对称加密机制、动手实现标准算法时缺乏可运行参考代码的痛点。压缩包共4个文件325KB含核心实现代码main.cpp、原理与步骤详解的PDF文档、简洁易读的Markdown说明及开源许可证文件覆盖从S盒查表、密钥扩展、轮函数到加解密全流程的C编码实践。已有552人学习下载代码采用std::vectoruint8_t规范处理字节块清晰分离SubBytes、ShiftRows、MixColumns等关键操作辅以注释详尽的流程说明便于读者逐轮调试、验证中间状态并深入理解AES128的加密逻辑与逆向解密结构。 最近花了一个周末把AES从理论到代码完整手撸了一遍整个过程比想象中更有意思。这个项目不复杂就是用纯CC17标准实现了一个不依赖OpenSSL、Crypto等任何第三方库的简易AES加解密算法支持AES-128/192/256三种密钥长度同时实现了ECB和CBC两种分组模式填充方式采用PKCS7。做这件事的动机有两个一是想彻底搞清楚AES内部那几个看起来神神秘秘的轮变换到底在做什么而不是永远停留在“调用一下库函数”的黑盒层面二是很多嵌入式设备、教学项目、课程设计场景里确实不方便引入重型加密库一个结构清晰、拿来即用的C版本代码会非常实用。这篇博文适合三类读者马上要交课程设计的学生、工作中遇到加密需求但不想直接黑盒调库的工程师以及单纯想把“AES很神秘”这层窗户纸捅破的程序员。我会从整体设计思路、AES核心原理、C代码实现、测试向量验证、常见坑位这五个方向展开尽量把每个环节的“为什么”讲清楚。1. 项目背景与整体设计思路1.1 为什么放着现成的加密库不用要自己写一个很多朋友看到“自己实现AES”的第一反应是何必呢OpenSSL一行代码就解决了。这话在工程实践上没问题但用在“搞懂原理”这个目标上就不成立了。我见过太多人用了好几年的AES却分不清SubBytes和ShiftRows分别负责什么也说不清楚为什么解密顺序和加密顺序不一样。自己写一个简易实现的真正价值在于你必须把每一个字节的流向都搞清楚必须理解GF(2^8)上的乘法为什么不能用整数乘法代替必须弄明白密钥扩展里的Rcon轮常量到底起什么作用。当你亲手把这些代码跑通拿到和标准测试向量一致的结果时那层窗户纸才算真正捅破。另外还有一个现实场景在资源受限的嵌入式环境、实验教学平台或者某些不能随意引入第三方依赖的系统中一个精简的AES实现可以直接编译运行不依赖外部库的版本匹配问题。这个项目的定位就是“教学可用、工程可移植、代码易读”所以我没有做太多极致优化而是优先保证逻辑清晰。1.2 模块划分与支持范围从ECB到CBCAES本身只定义了单块加密算法也就是把16字节明文变成16字节密文。但实际使用中数据往往超过16字节这时候就需要“分组模式”来决定多块数据之间的关联方式。我实现了两种模式ECB和CBC。ECB模式最简单每一块独立加密块与块之间没有关联所以我把它作为底层单块加密的验证入口。CBC模式则在加密前把当前明文块和上一个密文块做异或这样相同的明文块在不同位置会得到不同的密文块安全性明显更好。实际工程中CBC用得远比ECB多但ECB便于测试和入门理解两个都实现可以很好地对比出分组模式的意义。填充方式采用PKCS7。AES分组长度固定16字节最后一组不足16字节时必须填充。PKCS7规则简单直接缺几个字节就填几比如缺5个字节就填5个0x05。如果数据刚好是16的倍数还要额外填充一整块16个0x10否则解密时无法区分“数据末尾碰巧是0x01”和“真实填充了0x01”这两种情况。1.3 代码结构怎么组织才清晰我采用了一个AES类来封装整个算法类的内部大体分成三个层次。底层是GF(2^8)有限域运算包括xtime倍乘、gfMul乘法、S盒生成、逆S盒生成。这一层不依赖任何类状态全是纯函数。中层是十六轮变换的基本动作包括SubBytes、ShiftRows、MixColumns、AddRoundKey以及它们各自的逆操作。上层才是对外接口密钥扩展、单块加解密、分组模式的组合与填充逻辑。这样分层的好处很明显出问题时可以一层一层定位。加密结果不对先查单块加密单块不对就逐步打印每一轮的state矩阵和FIPS-197文档里的中间值比对。如果单块加密是对的但整体结果不对问题一定出在分组模式或填充上不用回头怀疑轮变换写错了。2. 先弄懂AES的数学地基从GF(2^8)到四个轮变换2.1 状态矩阵和字节序一个容易被忽略的坑AES加解密的单位是128位也就是16字节。这16字节不是简单地当作一个数组去处理而是要排列成一个4x4的状态矩阵。关键点在于输入字节是按“列优先”顺序填入矩阵的。举个例子输入十六进制序列00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f对应的状态矩阵是00 04 08 0c 01 05 09 0d 02 06 0a 0e 03 07 0b 0f也就是说前四个字节填第0列接下来的四个字节填第1列。很多初学者第一次写AES就是在这一步栽的跟头按照“行优先”填矩阵后续所有轮变换全部错位最后加密结果和标准向量对不上。我自己在代码里用state_[r][c]表示第r行第c列输入时写in[r 4c]输出时用out[r 4c]从代码上就固定了列优先的时空一致性。这个细节看着小但真的能省掉大量排查时间。2.2 SubBytes、S盒与乘法逆元唯一一处非线性SubBytes是AES中唯一引入非线性的步骤。这个名字翻译过来就是“字节代换”每一个状态字节通过查S盒表变成另一个字节。S盒是一个256字节的查找表不是随便拍脑袋生成的它的构造过程包含两个步骤。第一步在GF(2^8)有限域中求字节的乘法逆元。所谓GF(2^8)可以理解成一个特殊的“字节运算规则集合”加法和减法都等价于异或运算乘法是基于一个不可约多项式x^8 x^4 x^3 x 1的模运算。乘以2有一个非常高效的实现叫xtime先把字节左移一位如果最高位是1就再异或0x1B。这个操作在MixColumns里也会反复用到是整个AES的数学基石。第二步对乘法逆元做仿射变换一个包含循环左移和异或0x63的位级变换。加这个仿射变换的目的是防止S盒存在过于简单的代数结构增加抗数学攻击的能力。不用被这些术语吓到实现层面就是几张表、几个异或和移位操作。我生成S盒的代码用了暴力查找乘法逆元的方式对每个字节遍历0到255找到哪个数和它相乘等于1。这个方法对于初始化一次、后续只查表的场景完全够用而且逻辑直观适合学习。追求性能的话可以改成扩展欧几里得算法但那不是这个项目的重点。2.3 ShiftRows与MixColumns扩散性的来源如果说SubBytes负责“混淆”那ShiftRows和MixColumns就是负责“扩散”的。混淆让字节和密钥之间的关系变得极其复杂扩散则让明文的一个比特变化尽可能快地影响到整个密文。ShiftRows的操作非常直观状态矩阵的第0行不动第1行循环左移1个字节第2行循环左移2个字节第3行循环左移3个字节。解密的时候反过来变成循环右移。这一步的作用是让不同列之间的数据开始交换打破列间的独立性。MixColumns就更有意思了。它把每一列的4个字节看成GF(2^8)上的多项式左乘一个固定的矩阵。直观理解就是做了一次“列内混合”新列的每个字节都由旧列所有字节按不同权重组合而成。加密时用的权重是2、3、1、1解密时用的逆矩阵权重是14、11、13、9。如果你第一次写解密函数最容易犯的错误就是把解密轮变换的顺序完全写成加密的逆序。实际操作中解密每一轮先做InvShiftRows再做InvSubBytes然后AddRoundKey最后做InvMixColumns这个顺序和加密轮的顺序并不是完全镜像的。原因是AddRoundKey是自逆操作它可以和InvMixColumns交换位置标准文档里给出的解密流程是调整后的等价形式。2.4 密钥扩展Rcon到底在干什么AES每一轮都要使用不同的轮密钥16字节的初始密钥需要被扩展成(Nr1)组轮密钥。比如AES-128有10轮就需要11组轮密钥总共176字节。这个扩展过程叫Key Expansion。扩展算法的核心思路是把密钥看成4字节一组的字后续每个字由前面某个字和更早的字异或得到。每隔Nk个字Nk是密钥长度除以32就会触发一个特殊处理先循环左移一个字节再对每个字节查S盒最后和轮常量Rcon异或。Rcon轮常量是个一维数组第1个是0x01后面每一项都是前一项在GF(2^8)上乘以2也就是反复做xtime操作0x01, 0x02, 0x04, 0x08, 0x10, 0x20...。为什么需要这个常数就是为了破坏密钥扩展中的对称性避免不同轮次之间的密钥出现可预测的关联。如果去掉Rcon整个轮密钥会表现出很强的规律性会极大削弱安全性。还有一点要注意AES-256的密钥扩展规则和AES-128、AES-192不完全一样。在AES-256中每隔Nk个字也就是每处理一组新字时除了常规的Rcon处理之外在字下标i模Nk等于4的时候还要额外做一次SubWord也就是把每个字节过一遍S盒。这个细节很容易被忽略我在做AES-256支持的时候就在这里吃过亏后面会详细说。3. C实现从S盒生成到完整加解密函数3.1 数据结构设计和S盒生成先看一下类的设计。为了可读性和移植性状态矩阵直接用嵌套数组表示密钥扩展结果用字数组保存。#include array #include cstdint #include cstring #include iostream #include stdexcept #include string #include vector using Byte uint8_t; // 无符号8位整数 using Word uint32_t; // 无符号32位整数 class AES { public: enum class Mode { ECB, CBC }; AES(const std::vectorByte key, Mode mode, const std::vectorByte iv {}) : mode_(mode), iv_(iv) { if (key.size() ! 16 key.size() ! 24 key.size() ! 32) { throw std::invalid_argument(key must be 16/24/32 bytes); } if (mode Mode::CBC iv.size() ! 16) { throw std::invalid_argument(iv must be 16 bytes); } Nk_ static_castint(key.size() / 4); Nr_ Nk_ 6; sbox_ generateSbox(); inv_sbox_ generateInvSbox(sbox_); key_.assign(key.begin(), key.end()); expandKey(); } std::vectorByte encrypt(const std::vectorByte plain); std::vectorByte decrypt(const std::vectorByte cipher); private: Mode mode_; std::vectorByte iv_; std::vectorByte key_; std::arraystd::arrayByte, 4, 4 state_{}; std::arrayWord, 60 roundKeys_{}; std::arrayByte, 256 sbox_{}; std::arrayByte, 256 inv_sbox_{}; int Nk_ 0; int Nr_ 0; void expandKey(); void encryptBlock(const Byte* in, Byte* out); void decryptBlock(const Byte* in, Byte* out); std::vectorByte pkcs7Pad(const std::vectorByte data); std::vectorByte pkcs7Unpad(const std::vectorByte data); };这里我把全部密钥扩展结果放在一个60字的数组里最大情况下AES-256需要15轮轮密钥一共60个字提前分配好避免使用动态内存。状态矩阵使用4x4嵌套数组行和列的语义在代码里一目了然。S盒生成是整个实现中最有“数学味”的部分。核心是先求GF(2^8)乘法逆元再做仿射变换代码实现如下namespace { Byte xtime(Byte a) { Byte r static_castByte(a 1); if (a 0x80) { r ^ 0x1B; } return r; } Byte gfMul(Byte a, Byte b) { Byte p 0; for (int i 0; i 8; i) { if (b 1) { p ^ a; } a xtime(a); b 1; } return p; } std::arrayByte, 256 generateSbox() { std::arrayByte, 256 sbox{}; for (int i 0; i 256; i) { Byte inv 0; if (i ! 0) { for (int j 1; j 256; j) { if (gfMul(static_castByte(i), static_castByte(j)) 1) { inv static_castByte(j); break; } } } Byte x inv; Byte v x ^ static_castByte((x 1) | (x 7)) ^ static_castByte((x 2) | (x 6)) ^ static_castByte((x 3) | (x 5)) ^ static_castByte((x 4) | (x 4)) ^ 0x63; sbox[static_castByte(i)] v; } return sbox; } std::arrayByte, 256 generateInvSbox(const std::arrayByte, 256 sbox) { std::arrayByte, 256 inv{}; for (int i 0; i 256; i) { inv[sbox[i]] static_castByte(i); } return inv; } } // namespace我生成逆S盒的方式不是重复一遍逆计算而是利用S盒做反向映射。因为S盒是可逆置换sbox[i] v那么inv_sbox[v] i。这个思路比直接求代码要清爽很多也更不容易出错。3.2 轮变换的C实现细节SubBytes和ShiftRows都比较直白。SubBytes就是把状态矩阵的每个字节替换成S盒中的对应值。ShiftRows的关键是正确处理循环移位的方向加密时第r行第c列的新值来自第r行第(cr)列解密反过来即可。void shiftRows() { std::arraystd::arrayByte, 4, 4 tmp state_; for (int r 0; r 4; r) { for (int c 0; c 4; c) { state_[r][c] tmp[r][(c r) % 4]; } } } void invShiftRows() { std::arraystd::arrayByte, 4, 4 tmp state_; for (int r 0; r 4; r) { for (int c 0; c 4; c) { state_[r][c] tmp[r][(c - r 4) % 4]; } } }MixColumns每一列的处理完全独立我直接针对每一列取出四个字节再做GF(2^8)乘法矩阵组合。加密用2、3、1、1矩阵解密用14、11、13、9矩阵。void mixColumns() { for (int c 0; c 4; c) { Byte s0 state_[0][c]; Byte s1 state_[1][c]; Byte s2 state_[2][c]; Byte s3 state_[3][c]; state_[0][c] gfMul(0x02, s0) ^ gfMul(0x03, s1) ^ s2 ^ s3; state_[1][c] s0 ^ gfMul(0x02, s1) ^ gfMul(0x03, s2) ^ s3; state_[2][c] s0 ^ s1 ^ gfMul(0x02, s2) ^ gfMul(0x03, s3); state_[3][c] gfMul(0x03, s0) ^ s1 ^ s2 ^ gfMul(0x02, s3); } }这里gfMul直接复用了前面用于S盒生成的GF(2^8)乘法函数。一开始我还担心性能不够但实测下来对几百字节的数据做加解密是完全感知不到延迟的。真要处理海量数据再考虑用查表法优化多项式乘法也不迟。AddRoundKey的实现要格外注意字节序。我的roundKeys_数组是按Word存储的每个Word的四个字节从高到低对应状态矩阵的0到3行。所以取第round轮第c列密钥的第r个字节时要右移24 - 8 * r位。完整代码如下void addRoundKey(int round) { for (int r 0; r 4; r) { for (int c 0; c 4; c) { Word w roundKeys_[round * 4 c]; Byte kb static_castByte((w (24 - 8 * r)) 0xFF); state_[r][c] ^ kb; } } }3.3 密钥扩展的具体实现密钥扩展遵循标准流程。先把原始密钥按每4字节一组拆成Word大端字节序。然后从第Nk个字开始递增生成每隔Nk个字触发一次Rcon处理AES-256还要在特定条件多一次SubWord。void AES::expandKey() { int totalWords 4 * (Nr_ 1); Word words[60] {}; for (int i 0; i Nk_; i) { words[i] (static_castWord(key_[4 * i]) 24) | (static_castWord(key_[4 * i 1]) 16) | (static_castWord(key_[4 * i 2]) 8) | static_castWord(key_[4 * i 3]); } Word rcon 0x01000000; for (int i Nk_; i totalWords; i) { Word temp words[i - 1]; if (i % Nk_ 0) { temp subWord(rotWord(temp)) ^ rcon; rcon static_castWord(xtime(static_castByte(rcon 24))) 24; } else if (Nk_ 6 i % Nk_ 4) { temp subWord(temp); } words[i] words[i - Nk_] ^ temp; } for (int i 0; i totalWords; i) { roundKeys_[i] words[i]; } }这个rcon动态更新的写法特别省事。不用提前准备整个轮常量表每遇到一次Rcon处理就把当前值用xtime乘一下得到下一轮要用的值。xtime是对rcon的最高字节做倍乘然后再左移24位回到原来的位置。这个操作从1开始依次得到2、4、8、16、32对应标准文档里的RC数组。一开始写AES-256时我没注意到那个“多一次SubWord”的坑。标准文档明确写着当密钥长度是256位且i对Nk取模等于4时temp要先做SubWord再做异或。我漏掉这个条件后用AES-256加密单块得到的结果和测试向量不一致排查了很久才发现。这个细节很坑但也让我真正记住了AES-256和AES-128密钥扩展的差异。3.4 单块加密解密的分步实现单块加密的流程严格按照FIPS-197文档存初始密钥然后循环前Nr-1轮依次做SubBytes、ShiftRows、MixColumns、AddRoundKey最后一轮略过MixColumns。void AES::encryptBlock(const Byte* in, Byte* out) { for (int r 0; r 4; r) { for (int c 0; c 4; c) { state_[r][c] in[r 4 * c]; } } p a hrefhttps://download.csdn.net/download/s1t16/88041004 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p
返回列表