简介:围绕AES128位加密过程的完整讲解文档,面向密码学入门者和需要实现对称加密算法的开发者,适合系统理解加密流程并获取可参考代码。压缩包内仅1个doc文件,大小约为110KB,内容涵盖AES算法设计背景、与DES对比的优缺点、迭代加密的轮变化结构,重点讲解Subbyte变换、ShiftRow行移位、MixColumn列混合和AddRoundKey密钥加层等核心操作,并结合128位分组加密流程逐步说明。文档以128位密钥为例展开,涉及加密轮数与密钥长度关系、密钥扩展程序的实现思路,同时给出由C#版本移植而来的C++源码以及部分AVR汇编参考片段,在VC7.1环境下编译调试成功,可直接用于工程参考或二次修改。已有1678人学习下载,对于想快速掌握AES128加密流程并获取可运行代码的读者而言,无论是用于课程设计还是工程集成,都具有不错的实用价值。
1. AES128位加密过程详解:对称加密里最该先搞懂的那个分组密码
接手过固件升级包加密模块的人都有这种经历:原开发者留下一段AES-128源码,注释写着“已用标准向量验证”,然后人就不在了。AES-128的加密过程,说白了就是16字节明文放进一个4×4矩阵,做10轮替换、移位、列混合、轮密钥加的循环,密钥和明文等长,加密解密共用同一套轮结构。这份资源把AES算法原理、C#到C++的实现移植、AVR汇编版优化都覆盖到了,适合三类人:要自己写一遍加解密实现的人,要review别人加密代码的人,以及学完FIPS-197想验证自己有没有理解透的人。下面按“轮函数→密钥扩展→类实现→踩坑→验证”的顺序拆开讲,每个环节都给到能直接抄的代码和参数边界。
2. 先拆四个轮操作:S盒、ShiftRows、MixColumns、AddRoundKey各自在干什么
AES把128位输入按字节放进4×4的状态矩阵State,之后每一轮都在这个矩阵上做四个操作。资源正文里的AVR汇编例程也是按这四个模块组织的,所以先把它们逐个拆透。
2.1 字节替换SubBytes:S盒查表比现场算逆元快一个量级
SubBytes是AES唯一的非线性环节,作用是把State里的每个字节映射成另一个字节。官方定义是先计算该字节在GF(2^8)域上的乘法逆元,再做一次仿射变换;其中0x00没有逆元,按约定直接映射到0x63。如果你按官方定义实现,每个字节都要做一次扩展欧几里得求逆,16字节就是16次,一帧数据几轮下来性能很难看。实际工程里没人现场算,都是预先把256个映射值生成好,运行时直接查表。S盒是16×16矩阵,行由字节高4位索引,列由低4位索引。
void sub_bytes(uint8_t s[4][4], const uint8_t sbox[16][16]) { for (int r = 0; r < 4; r++) { for (int c = 0; c < 4; c++) { uint8_t v = s[r][c]; s[r][c] = sbox[v >> 4][v & 0x0f]; } } }逻辑说明:输入字节v拆成高4位和低4位,分别作为S盒矩阵的行、列索引,用查表结果覆盖原字节。参数里s是4×4状态矩阵,sbox是预先生成的标准S盒,生成时建议用公式计算逆元后再做仿射变换,而不是手抄常量表,原因在避坑章细说。查表法在AVR这种8位单片机上尤其划算,资源里的汇编子程序Schange就是先把S盒首地址放进Z指针,再用查表指令LPM取出替换值,本质上和这段C代码做的是同一件事。
2.2 行位移ShiftRows:偏移量为什么是0、1、2、3
ShiftRows是一个纯置换操作,把State的每一行按行号循环左移:第0行不动,第1行左移1字节,第2行左移2字节,第3行左移3字节。这样做的目的是让明文里同一列的字节扩散到不同列,为后面MixColumns的列混合提供交叉输入。解密时反向操作,第1行右移1字节,第2行右移2字节,第3行右移3字节。
void shift_rows(uint8_t s[4][4]) { uint8_t t; t = s[1][0]; s[1][0] = s[1][1]; s[1][1] = s[1][2]; s[1][2] = s[1][3]; s[1][3] = t; t = s[2][0]; s[2][0] = s[2][2]; s[2][2] = t; t = s[2][1]; s[2][1] = s[2][3]; s[2][3] = t; t = s[3][0]; s[3][0] = s[3][3]; s[3][3] = s[3][2]; s[3][2] = s[3][1]; s[3][1] = t; }逻辑说明:这段代码用临时变量做循环交换,第1行做1字节循环左移,第2行做2字节对调,第3行做3字节循环左移。注意这里操作的是State的行,State的行对应明文的列存储,这也是新手画矩阵时最容易绕晕的地方。AES规范里输入字节是按列填进State的,所以在ShiftRows眼里“行”和你按行读明文数组时的“行”不是同一个概念,写代码前先把填充方式定死,否则后面全对不上。
2.3 列混合MixColumns:GF(2^8)乘法就是xtime条件左移
MixColumns是AES里最劝退的部分,原理是GF(2^8)多项式乘法,但实现上只需要两个工具:加法就是异或,乘0x02就是一次条件左移。所谓条件左移,是把字节左移一位,如果原字节最高位是1,移位结果还要再异或0x1b,这个0x1b对应GF(2^8)的不可约多项式x^8+x^4+x^3+x+1。
uint8_t xtime(uint8_t a) { uint8_t r = (uint8_t)(a << 1); if (a & 0x80) r ^= 0x1b; return r; } void mix_column(uint8_t *col) { uint8_t t = col[0] ^ col[1] ^ col[2] ^ col[3]; uint8_t u = col[0]; col[0] ^= t ^ xtime(col[0] ^ col[1]); col[1] ^= t ^ xtime(col[1] ^ col[2]); col[2] ^= t ^ xtime(col[2] ^ col[3]); col[3] ^= t ^ xtime(col[3] ^ u); }逻辑说明:MixColumns把每列4字节看作一个列向量,左乘一个固定矩阵。这个矩阵第一行是02 03 01 01,所以新列的第一个字节等于2·a0 ⊕ 3·a1 ⊕ a2 ⊕ a3。上面代码里的异或链就是在算这个线性组合:t是所有4字节的异或,u保存原col[0]供最后一行使用。xtime的参数是a时返回2·a,两个参数异或后再xtime等价于乘3,因为3·x = (2·x) ⊕ x。
参数说明:col指向State某一列的4个字节。资源里的AVR汇编版把xtime写成了子程序,核心就是lsl temp左移、brcs next1判断进位、eor temp,temp1异或0x1b三段,和这段C代码完全对应。解密时列混合矩阵系数变成09 0E 0B 0D,因为这几个系数都能由02和04组合出来,比如0x0D = 0x08 ⊕ 0x04 ⊕ 0x01,可以拆成三次xtime再加原值,避免做通用GF乘法。
2.4 轮密钥加AddRoundKey:异或让加密解密天然对称
AddRoundKey是把State的16个字节和当前轮的16字节轮密钥逐字节异或。因为异或操作是可逆且自反的,加密时异或一次轮密钥,解密时再异或同一个轮密钥就能还原,这也是AES能复用同一套轮结构做加解密的基础。
void add_round_key(uint8_t s[4][4], uint8_t w[44][4], int round) { for (int c = 0; c < 4; c++) { for (int r = 0; r < 4; r++) { s[r][c] ^= w[round * 4 + c][r]; } } }逻辑说明:round是当前轮号,轮密钥从扩展密钥数组w中按4字一组取出。这里w的每一行是一个4字节字,w[round4 + c]取第round轮的第c个字,再用[r]索引到具体字节,正好和State的列下标对应。为什么不是w[c][r]而是w[round4+c][r]?因为AES规范里密钥调度表w是按字组织的,State是按字节矩阵组织的,两者索引方向恰好相反,这一点在移植时特别容易写拧。
3. 加密主流程与密钥扩展:十轮循环里最容易写错的两处
四个轮操作都清楚了,接下来要把它们串成完整流程。128位密钥的加密一共10轮,但轮数和密钥扩展都有隐藏细节。
3.1 轮数与State矩阵:128位密钥为什么是10轮
AES的块长固定128位,也就是16字节,State矩阵永远是4×4。轮数只由密钥长度决定,规则如下表:
| 密钥长度 | Nk(字) | 轮数Nr | 扩展密钥总字节数 |
|---|---|---|---|
| 128位 | 4 | 10 | 4×16 = 176字节 |
| 192位 | 6 | 12 | 4×16 = 208字节 |
| 256位 | 8 | 14 | 4×16 = 240字节 |
注意最后一行,16是State按字节算的边长,4×(Nr+1)是扩展密钥按字算的总字数。128位密钥对应44个字,每个字4字节,共176字节。加密每轮用掉16字节轮密钥,初始AddRoundKey用1组,10轮用10组,正好11组176字节。网上有些教程写“128位密钥10轮就够,不需要扩展那么多”,那是没把初始轮算进去。解密侧同样需要这176字节,只是使用顺序倒过来。
3.2 密钥扩展KeyExpansion:RotWord、SubWord、Rcon三个配角
密钥扩展的任务是把16字节种子密钥膨胀成176字节轮密钥。核心循环是:扩展密钥前4个字直接拷贝种子密钥;之后的每个字等于前一个字和前面第4个字的异或;凡是下标为4的倍数的字,先对前一个字做RotWord循环左移1字节,再做SubWord按S盒逐字节替换,最后异或一个轮常数Rcon[i/4]。
void key_expansion(const uint8_t *key, uint8_t w[44][4], const uint8_t sbox[16][16]) { for (int i = 0; i < 4; i++) { for (int j = 0; j < 4; j++) { w[i][j] = key[i * 4 + j]; } } for (int i = 4; i < 44; i++) { uint8_t temp[4]; for (int j = 0; j < 4; j++) temp[j] = w[i - 1][j]; if (i % 4 == 0) { uint8_t t = temp[0]; temp[0] = temp[1]; temp[1] = temp[2]; temp[2] = temp[3]; temp[3] = t; for (int j = 0; j < 4; j++) { uint8_t v = temp[j]; temp[j] = sbox[v >> 4][v & 0x0f]; } temp[0] ^= rcon[i / 4]; } for (int j = 0; j < 4; j++) { w[i][j] = w[i - 4][j] ^ temp[j]; } } }逻辑说明:i从4开始到43结束,每轮生成一个字。i%4==0时先RotWord,再SubWord,再异或Rcon,最后才异或w[i-4]。这个顺序错了,w[4]对不上标准答案。参数说明:sbox是标准S盒,rcon是轮常数表,rcon[1]=0x01,之后每个是前一个的xtime倍,rcon[2]=0x02,rcon[3]=0x04,依此类推,到rcon[10]=0x36。
用手算验证一遍w[4]能快速确认代码没写歪。种子密钥是000102030405060708090a0b0c0d0e0f时,w[3]=0c0d0e0f。RotWord后变成0d0e0f0c,SubWord查表:0d→d7,0e→ab,0f→76,0c→fe,得到d7ab76fe。异或Rcon[1]=01000000得到d6ab76fe,再异或w[0]=00010203得到d6aa74fd,这就是FIPS-197附录A里w[4]的标准值。能对上这一行,说明RotWord方向和S盒查表都对。
3.3 Cipher主循环:前9轮完整四步,最后一轮去掉MixColumns
加密主流程的伪代码是:先把明文拷入State,做一次AddRoundKey;然后循环Nr−1次完整轮(SubBytes、ShiftRows、MixColumns、AddRoundKey);最后再执行一次不含MixColumns的收尾轮。128位密钥就是9次完整轮加1次收尾轮。
void cipher(const uint8_t *in, uint8_t *out, uint8_t w[44][4], const uint8_t sbox[16][16]) { uint8_t s[4][4]; for (int r = 0; r < 4; r++) { for (int c = 0; c < 4; c++) { s[c][r] = in[r * 4 + c]; } } add_round_key(s, w, 0); for (int round = 1; round <= 9; round++) { sub_bytes(s, sbox); shift_rows(s); mix_columns_all(s); add_round_key(s, w, round); } sub_bytes(s, sbox); shift_rows(s); add_round_key(s, w, 10); for (int r = 0; r < 4; r++) { for (int c = 0; c < 4; c++) { out[r * 4 + c] = s[c][r]; } } }逻辑说明:两组双层循环分别负责明文进矩阵和密文出矩阵,填充方向都是按列来,即in的第r×4+c字节放到s[c][r],不是s[r][c]。中间循环从round=1到9,注意如果从0开始或者循环10次,会多执行一轮MixColumns,密文直接报废。最后一轮先做SubBytes和ShiftRows,再AddRoundKey,收尾轮的轮密钥取w[40]到w[43]。资源里C#原版的Cipher方法就是按这个结构写的,把MixColumns调用写在循环体外,而不是用if判断是不是最后一轮,这个习惯很好,能少踩一个坑。
4. 落成一版可用的AES-128类:从C#移植到C++的几个关键点
这份资源最实用的部分是那个从C#版照搬过来的C++类,在VC7.1下编译调试过。C#和C++在数组、类型转换上的差异,会在移植时埋不少雷,这一章把类结构和移植关键点过一遍。
4.1 类骨架与数据成员:为什么State是4×4而w是44×4
C#原版用了一个枚举KeySize和九个私有成员,C++移植版照这个骨架来是最省事的。State矩阵就是当前正在处理的16字节块,w数组是扩展密钥,Sbox和iSbox分别是加密和解密要用的替换表。
enum KeySize { Bits128, Bits192, Bits256 }; class Aes { public: Aes(KeySize keySize, const uint8_t *keyBytes); void Cipher(const uint8_t *plain, uint8_t *cipher); void InvCipher(const uint8_t *cipher, uint8_t *plain); private: int Nb, Nk, Nr; uint8_t key[32]; uint8_t Sbox[16][16]; uint8_t iSbox[16][16]; uint8_t w[60][4]; // 按最大256位密钥44*?预留 uint8_t Rcon[14]; uint8_t State[4][4]; };逻辑说明:Nb是块长按字计,永远是4;Nk是密钥按字计,128位是4,192位是6,256位是8;Nr是轮数。w数组的行数按最大密钥算,256位密钥需要4×(14+1)=60行,所以声明成60×4而不是44×4,避免越界。C#版里w用的是二维数组,C++里用数组加指针传参都行,关键是索引别混。
4.2 构造函数顺序:先建四张表,再做密钥扩展
C#构造函数的调用顺序是:SetNbNkNr设置参数,拷贝种子密钥,BuildSbox、BuildInvSbox、BuildRcon,最后KeyExpansion。这个顺序不能乱,因为KeyExpansion要用到Sbox,BuildRcon要用到xtime,而xtime不依赖任何表。C#里byte异或不能直接写,要强转成int再转回byte,C++里虽然可以直接^,但uint8_t会先提升成int,赋值回去时要注意截断。
Aes::Aes(KeySize keySize, const uint8_t *keyBytes) { SetNbNkNr(keySize); memcpy(this->key, keyBytes, this->Nk * 4); BuildSbox(); BuildInvSbox(); BuildRcon(); KeyExpansion(); } void Aes::BuildRcon() { Rcon[1] = 0x01; for (int i = 2; i <= 10; i++) { Rcon[i] = xtime(Rcon[i - 1]); } }逻辑说明:Rcon表的下标从1开始,Rcon[1]到Rcon[10]对应10轮密钥扩展的需要。BuildRcon用xtime累乘,比手写常量表省事且不会抄错。参数说明:BuildSbox和BuildInvSbox在构造函数里执行,加密解密都要用;如果只做加密,iSbox可以不建,但留着一来代码结构对称,二来后面加解密都要测。
4.3 测试向量校验:第一步就卡住你的不是算法而是字节序
C#版的Cipher方法有现成测试向量:密钥000102030405060708090a0b0c0d0e0f,明文00112233445566778899aabbccddeeff,密文应该是69c4e0d86a7b0430d8cdb78070b4c55a。移植完第一件事就是拿这个向量跑,别急着接业务数据。建议直接在构造函数里留一个静态自检函数,或者写个独立的check函数。
bool aes_self_test() { uint8_t key[16] = { 0x00,0x01,0x02,0x03,0x04,0x05,0x06,0x07, 0x08,0x09,0x0a,0x0b,0x0c,0x0d,0x0e,0x0f }; uint8_t plain[16] = { 0x00,0x11,0x22,0x33,0x44,0x55,0x66,0x77, 0x88,0x99,0xaa,0xbb,0xcc,0xdd,0xee,0xff }; uint8_t expect[16] = { 0x69,0xc4,0xe0,0xd8,0x6a,0x7b,0x04,0x30, 0xd8,0xcd,0xb7,0x80,0x70,0xb4,0xc5,0x5a }; Aes aes(Bits128, key); uint8_t out[16]; aes.Cipher(plain, out); return memcmp(out, expect, 16) == 0; }逻辑说明:这个测试向量来自FIPS-197,是AES-128的官方样例。注意输入数组按字节顺序写,Stata填充和输出提取都是列优先,如果填充方向搞错,第一个字节就错,根本走不到后面。之前我给别人review一个移植版,对方信誓旦旦说“测试向量过了”,结果检查发现他把期望密文大小写混淆,0x5a写成了0x5A,程序当然“过”了。所以测试向量最好从标准文档复制,别手打。
解密侧同理,InvCipher处理同一组密文应还原明文。解密流程是加密的逆序:先用最后一组轮密钥做AddRoundKey,再循环应用InvShiftRows、InvSubBytes、AddRoundKey、InvMixColumns,注意这里InvMixColumns和InvShiftRows的顺序与加密相反,先逆行位移再逆字节替换,轮密钥也是从w[40]倒着用到w[0]。
5. AES-128避坑与常见问题:解密失败十个有九个是这些原因
从自己写第一版AES到现在,整理五条最常踩的坑,每条都是改完代码后眼睛瞪出血的经验。
5.1 解密出来前16字节正常后面全乱:填充和模式没配对
现象:用ECB模式加密一段超过16字节的文件,解密后第一块是对的,从第二块开始全是乱码。原因:AES是分组密码,每组16字节,多组数据需要按模式串联。ECB模式下每块独立加解密,理论上不该出现第二块乱——出现这种情况多半是解密时根本没把数据切成16字节块,或者加密时用的填充和解密时用的填充不一致。解决:先确认多分组处理逻辑,再检查填充。常见做法是PKCS7,加密时数据末尾补N个字节,每个字节值都是N;解密后必须校验每个填充字节是否都等于N,不能只剥一个字节就走。
5.2 密钥扩展w[4]和参考值对不上:RotWord方向拧了
现象:密钥扩展跑完,w[4]输出不是d6aa74fd,而是别的值。原因:RotWord要求把[A,B,C,D]旋转成[B,C,D,A],有人会写反成[D,A,B,C];还有一种原因是SubWord查表时高低4位取反,0x0d查成了d0。解决:用手算校准。w[3]=0c0d0e0f,旋转后必须是0d0e0f0c,查表得到d7ab76fe,异或Rcon和w[0]得到d6aa74fd。哪一步对不上就先打印哪一步的中间值,三个配角分开验证。
5.3 S盒手抄错一行:用公式生成别用眼睛抄
现象:加密结果和测试向量对比,其他字节都对,个别字节差一位。原因:S盒是16×16共256个值,手抄很容易把0c0d0e0f那一行的顺序抄串;还有资料把0x63开头那行写错成0x62的,抄的人也照错。解决:别手抄。代码里用GF(2^8)乘法逆元加仿射变换现场生成S盒,生成完再和标准表逐项比对。资源里C#版是BuildSbox方法生成的,照它的公式来,比Ctrl+C粘贴一个可能被改过的表可靠。
5.4 MixColumns算错:xtime的溢出判断丢了
现象:单字节测试时AES的SubBytes和ShiftRows都对,一到MixColumns整体输出就偏移。原因:xtime左移后没有判断原值最高位是否为1,或者判断了但没有异或0x1b。在GF(2^8)里,溢出后的修正不是丢弃进位而是异或不可约多项式。解决:单独写一个xtime自检,验证xtime(0x80)的结果等于0x1b,xtime(0x53)的结果等于0xa6。这两个值对上了,GF乘法基本不会错。顺便说一句,解密列混合系数09 0E 0B 0D比加密侧大,所以常规实现解密本来就比加密慢,资源里提到AVR汇编的优化方法就是拆解这些系数减少倍乘次数。
5.5 最后一轮多做了MixColumns:循环边界没对齐
现象:前几轮都对着,最后一轮加密结果从第某块开始全错。原因:轮函数循环从0到Nr跑全量,把最后一轮的MixColumns也执行了。AES规范的Cipher伪代码里,最后一轮明确没有MixColumns。解决:按3.3的结构,循环执行Nr−1次完整轮,最后单独做一次SubBytes、ShiftRows、AddRoundKey。写成if (round != Nr)再调MixColumns也行,但那样每轮都要多一次分支判断,不如拆开写直观。类似的坑还有解密时忘了把轮密钥倒序用,都是从w[0]开始解,结果第一轮就错。
6. 验证AES-128实现的三种手段:别让测试向量成为唯一标准
测试向量过了只能说明单分组算法实现正确,放到真实数据流里还要再过三关。
6.1 单分组测试向量是最低门槛,但要多取几组
FIPS-197附录B和C给了多组由简到繁的测试数据,AES-128、192、256都有。不要只测一组就收工,至少把附录B的三组全跑一遍。如果字节序有问题,可能恰好某一组数据碰巧不受影响。建议把测试向量做成自动化用例,每次改动代码后一键回归,而不是临时手敲。
6.2 加解密往返一致性加雪崩检查
自检函数里加上解密验证:Cipher得到密文,InvCipher解回明文,逐字节比较。一致性过了只能说明加解密互逆,不能说明算法正确。再补一个雪崩测试:对明文只翻转一个bit,加密后统计密文变化了的bit数,128位里应当接近64位。低于50位或者高于80位都要警惕,说明某些轮操作的扩散性没起到作用。
openssl enc -aes-128-ecb -K 000102030405060708090a0b0c0d0e0f \ -nopad -in plain16.bin -out cipher.bin xxd cipher.bin逻辑说明:OpenSSL的输出可以作为黑匣子对照。参数里-K后面跟十六进制密钥,不带冒号;-nopad是关掉OpenSSL默认的PKCS7填充,这样单分组加密结果可以直接和自写代码对比。plain16.bin必须是正好16字节的文件,多了少了都会报错。OpenSSL默认用CBC模式,所以显式加-ecb。把输出和测试向量比,OpenSSL一致基本可以确认算法流程没跑偏。
从那以后我每次拿到一份AES实现,都强制走一遍测试向量、往返一致性和雪崩检查三件套,这个流程救过我好几次——最近一次是发现对方在密钥拷贝时把16字节拷成了24字节,而测试向量恰好只用了前16字节,雪崩测试一改明文就露馅了。密码算法这种东西,差一个bit都是黑匣子,千万别拿“看起来能跑”当标准。希望帮到你。
本文还有配套的精品资源,点击获取