C语言从零实现AES-128加密算法:原理详解与代码实战
1. 项目概述与核心价值
最近在整理一些旧项目时,发现很多涉及数据安全传输和存储的模块,其核心都离不开一个老朋友——AES加密算法。无论是嵌入式设备间的通信,还是桌面应用对配置文件的保护,AES因其高效、安全且被广泛支持的特性,成为了事实上的对称加密标准。虽然现在有很多成熟的开源库(如OpenSSL, mbedTLS)可以直接调用,但亲手用C语言从零实现一遍AES的加密和解密过程,对于深入理解其算法原理、内存操作以及提升对密码学的直观感受,有着不可替代的价值。这不仅仅是完成一个功能,更像是一次对计算机底层运算和密码学设计的深度探索。
这个项目适合所有对密码学感兴趣、希望夯实C语言功底,或者需要在资源受限环境(如无标准库的MCU)中实现加密功能的开发者。通过这个过程,你将不仅仅得到一段可运行的AES代码,更能透彻理解字节代换(SubBytes)、行移位(ShiftRows)、列混合(MixColumns)和轮密钥加(AddRoundKey)这四大核心步骤是如何环环相扣,将一段明文转化为密文的。我们会从AES-128(密钥长度128位)这个最常用的版本开始,一步步拆解,并提供可直接集成使用的代码模块。
2. AES-128算法原理深度拆解
在动手写代码之前,我们必须先搞清楚AES到底在做什么。AES(Advanced Encryption Standard,高级加密标准)是一种分组密码算法,它把待加密的数据分成固定长度的“块”(Block,AES固定为128位,即16字节),然后用一个密钥(Key,这里用128位)对这个块进行多轮复杂的变换。
2.1 状态矩阵与初始映射
AES算法内部并不直接处理一维的字节数组,而是将其视为一个4x4的二维矩阵,称为“状态”(State)。这个状态矩阵是按列优先的顺序填充的。假设我们的16字节明文输入是in[0], in[1], ..., in[15],那么状态矩阵state[r][c](r为行,c为列)的填充方式如下:
state[0][0] = in[0], state[1][0] = in[1], state[2][0] = in[2], state[3][0] = in[3] // 第一列 state[0][1] = in[4], state[1][1] = in[5], state[2][1] = in[6], state[3][1] = in[7] // 第二列 ... 以此类推理解这个映射关系至关重要,因为后续所有操作都是基于这个4x4状态矩阵进行的。加密过程结束后,我们再按同样的列优先顺序将状态矩阵还原为一维字节数组输出。
2.2 轮函数详解:四大核心步骤
对于AES-128,完整的加密过程包含10轮(Round)运算。第1轮到第9轮每轮都包含四个步骤,最后一轮(第10轮)缺少列混合步骤。解密过程则是这些步骤的逆序逆运算。
1. 字节代换(SubBytes)这是一个非线性变换,是AES能够抵抗各种密码分析的关键。它通过一个被称为S盒(Substitution-box)的查找表,将状态矩阵中的每一个字节替换成另一个字节。这个S盒是经过精心设计的,提供了良好的非线性特性。在代码实现中,我们会预先计算好这个256字节的S盒数组,查表即可完成替换,效率极高。逆操作查的是逆S盒(InvSubBytes)。
注意:S盒的设计是公开的,但其背后涉及有限域GF(2^8)上的求逆和仿射变换。对于我们实现而言,直接使用标准的规定值即可,无需自己计算生成,但理解其数学背景能让你明白为何这样设计。
2. 行移位(ShiftRows)这是一个线性变换,目的是让字节在行间扩散。操作很简单:
- 第0行:不移位。
- 第1行:循环左移1个字节。
- 第2行:循环左移2个字节。
- 第3行:循环左移3个字节。 这个操作打破了每列字节的独立性,让加密效果更快地扩散到整个数据块。逆操作是循环右移相应的位数。
3. 列混合(MixColumns)这是加密过程中最复杂的步骤,它对状态矩阵的每一列进行独立的变换。可以将其理解为在GF(2^8)有限域上,将每一列与一个固定的多项式c(x)进行模乘运算。这个运算可以表示为一个矩阵乘法。同样,我们会通过查表(使用事先计算好的xtime表或组合查表法)来高效实现,而不是在现场进行复杂的有限域运算。逆操作是乘以另一个固定的多项式d(x)。
4. 轮密钥加(AddRoundKey)这是最简单的一步,将当前的状态矩阵与一个本轮专用的“轮密钥”(Round Key)进行逐字节的异或(XOR)操作。轮密钥是从初始的主密钥通过一个称为“密钥扩展”(Key Expansion)的算法派生出来的一系列128位密钥。每一轮使用的轮密钥都不同,这大大增加了安全性。
2.3 密钥扩展(Key Expansion)
密钥扩展算法将输入的128位主密钥,扩展成11个128位的轮密钥(用于初始轮密钥加和后续10轮运算)。扩展过程也涉及S盒替换、轮常量(Rcon)异或等操作。这部分需要单独的函数来实现,并且通常会在加密/解密初始化时一次性计算好所有轮密钥并存储起来,避免在每轮中重复计算。
3. C语言实现的核心模块与数据结构设计
理解了原理,我们就可以开始设计代码结构了。一个清晰、模块化的设计会让调试和理解变得容易。
3.1 基础类型与状态定义
首先,我们定义一些基础类型和状态矩阵。为了清晰和可移植性,我们使用uint8_t来表示字节。
#include <stdint.h> // 用于uint8_t等类型 // AES-128 密钥长度和块大小(字节) #define AES_KEYLEN 16 #define AES_BLOCKLEN 16 // 状态矩阵:4行,Nb列(Nb固定为4,代表4个字,即16字节) typedef uint8_t state_t[4][4];状态矩阵state_t是一个4x4的二维数组,我们将围绕它进行操作。
3.2 核心变换函数的实现
1. SubBytes 与 InvSubBytes我们首先定义标准的S盒和逆S盒(数据较长,此处仅示意):
static const uint8_t sbox[256] = { 0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76, // ... 剩余240个值,需补全完整的256字节 }; static const uint8_t inv_sbox[256] = { 0x52, 0x09, 0x6a, 0xd5, 0x30, 0x36, 0xa5, 0x38, 0xbf, 0x40, 0xa3, 0x9e, 0x81, 0xf3, 0xd7, 0xfb, // ... 剩余240个值 }; void SubBytes(state_t *state) { for (int i = 0; i < 4; ++i) { for (int j = 0; j < 4; ++j) { (*state)[i][j] = sbox[(*state)[i][j]]; } } } void InvSubBytes(state_t *state) { for (int i = 0; i < 4; ++i) { for (int j = 0; j < 4; ++j) { (*state)[i][j] = inv_sbox[(*state)[i][j]]; } } }2. ShiftRows 与 InvShiftRows实现行移位需要注意边界处理,使用循环移位。
void ShiftRows(state_t *state) { uint8_t 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位 - 等价于交换两对字节 temp = (*state)[2][0]; (*state)[2][0] = (*state)[2][2]; (*state)[2][2] = temp; temp = (*state)[2][1]; (*state)[2][1] = (*state)[2][3]; (*state)[2][3] = temp; // 第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; } void InvShiftRows(state_t *state) { uint8_t temp; // 第1行循环右移1位 temp = (*state)[1][3]; (*state)[1][3] = (*state)[1][2]; (*state)[1][2] = (*state)[1][1]; (*state)[1][1] = (*state)[1][0]; (*state)[1][0] = temp; // 第2行循环右移2位 - 交换与ShiftRows相同 temp = (*state)[2][0]; (*state)[2][0] = (*state)[2][2]; (*state)[2][2] = temp; temp = (*state)[2][1]; (*state)[2][1] = (*state)[2][3]; (*state)[2][3] = temp; // 第3行循环右移3位 - 等价于循环左移1位 temp = (*state)[3][0]; (*state)[3][0] = (*state)[3][1]; (*state)[3][1] = (*state)[3][2]; (*state)[3][2] = (*state)[3][3]; (*state)[3][3] = temp; }3. MixColumns 与 InvMixColumns这是实现中最易出错的部分。我们采用查表法优化。首先实现有限域GF(2^8)上的xtime函数(乘以x,即{02}):
static inline uint8_t xtime(uint8_t x) { return ((x << 1) ^ (((x >> 7) & 1) * 0x1b)); }然后实现单列的混合运算。加密时,每一列[a0, a1, a2, a3]^T经过如下矩阵乘法:
[02 03 01 01] [a0] [01 02 03 01] * [a1] [01 01 02 03] [a2] [03 01 01 02] [a3]我们可以手动计算每一步,但更高效的做法是使用预先计算好的T表(这里为简化,展示直接计算版本):
void MixColumns(state_t *state) { for (int i = 0; i < 4; ++i) { // 处理每一列 uint8_t t = (*state)[0][i]; uint8_t Tmp = (*state)[0][i] ^ (*state)[1][i] ^ (*state)[2][i] ^ (*state)[3][i]; uint8_t Tm = (*state)[0][i] ^ (*state)[1][i]; Tm = xtime(Tm); (*state)[0][i] ^= Tm ^ Tmp; Tm = (*state)[1][i] ^ (*state)[2][i]; Tm = xtime(Tm); (*state)[1][i] ^= Tm ^ Tmp; Tm = (*state)[2][i] ^ (*state)[3][i]; Tm = xtime(Tm); (*state)[2][i] ^= Tm ^ Tmp; Tm = (*state)[3][i] ^ t; Tm = xtime(Tm); (*state)[3][i] ^= Tm ^ Tmp; } }解密时的逆列混合矩阵不同,实现也更为复杂,通常直接使用另一个基于xtime和有限域乘法的计算序列,或者使用组合的查表法。为了代码清晰,我们可以单独实现InvMixColumns函数,其核心是乘以{0e, 0b, 0d, 09}等系数,这可以通过多次调用xtime和异或来实现。
4. AddRoundKey这一步最简单,但要注意轮密钥的排列顺序。轮密钥也是一个4x4的矩阵,直接按字节异或。
void AddRoundKey(state_t *state, const uint8_t *round_key) { for (int i = 0; i < 4; ++i) { for (int j = 0; j < 4; ++j) { (*state)[i][j] ^= round_key[i + 4 * j]; // 注意轮密钥的索引方式 } } }这里有一个关键细节:轮密钥在内存中通常是以一维数组uint8_t round_key[16]形式存储的,其列优先顺序与状态矩阵的填充顺序一致。所以round_key[0]对应状态state[0][0],round_key[4]对应state[0][1],以此类推。上面代码中的索引i + 4 * j正是为了匹配这个顺序。
3.3 密钥扩展算法的实现
密钥扩展是独立的且至关重要的模块。它将16字节的主密钥扩展成11个轮密钥(共176字节)。
// 轮常量表 Rcon, 实际只需要前10个值 static const uint8_t Rcon[11] = { 0x00, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36 }; void KeyExpansion(const uint8_t *key, uint8_t *round_keys) { uint8_t temp[4]; int i = 0; // 第一个轮密钥就是原始密钥 for (i = 0; i < AES_KEYLEN; ++i) { round_keys[i] = key[i]; } // 生成后续轮密钥 while (i < (AES_BLOCKLEN * (AES_NR + 1))) { // AES_NR=10, 共11轮密钥 // 取前一个轮密钥的最后4个字节 for (int j = 0; j < 4; ++j) { temp[j] = round_keys[i - 4 + j]; } if (i % AES_KEYLEN == 0) { // 密钥扩展核心函数:RotWord, SubWord, Rcon // 1. 循环左移一个字节 (RotWord) uint8_t t = temp[0]; temp[0] = temp[1]; temp[1] = temp[2]; temp[2] = temp[3]; temp[3] = t; // 2. S盒替换 (SubWord) for (int j = 0; j < 4; ++j) { temp[j] = sbox[temp[j]]; } // 3. 与轮常量异或 temp[0] ^= Rcon[i / AES_KEYLEN]; } // 对于AES-256这里还有额外判断,AES-128不需要 // 生成新的4个字节:与前一个轮密钥的对应4字节异或 for (int j = 0; j < 4; ++j) { round_keys[i] = round_keys[i - AES_KEYLEN] ^ temp[j]; i++; } } }这段代码是AES-128密钥扩展的标准实现。RotWord是循环左移,SubWord是S盒替换,然后与Rcon异或,最后再与上一轮密钥的对应部分异或,得到新的轮密钥材料。理解这个过程有助于明白轮密钥之间的非线性关系是如何建立的。
4. 加密与解密流程的完整整合
有了所有的基础函数,我们现在可以将它们组装成完整的加密和解密流程。我们需要一个上下文结构体来保存扩展后的轮密钥。
4.1 定义AES上下文结构
typedef struct { uint8_t round_keys[(AES_NR + 1) * AES_BLOCKLEN]; // 存储扩展后的所有轮密钥 } AES_ctx;4.2 加密主函数(Cipher)
加密函数遵循标准的AES流程:初始轮密钥加 -> 9轮完整轮函数 -> 最后一轮(无MixColumns)。
void AES_EncryptBlock(AES_ctx *ctx, const uint8_t *input, uint8_t *output) { state_t state; uint8_t round = 0; // 1. 将输入明文拷贝到状态矩阵(列优先) for (int i = 0; i < 4; ++i) { for (int j = 0; j < 4; ++j) { state[i][j] = input[i + 4 * j]; } } // 2. 初始轮密钥加 AddRoundKey(&state, &(ctx->round_keys[round * AES_BLOCKLEN])); round++; // 3. 前9轮完整运算 for (; round < AES_NR; ++round) { SubBytes(&state); ShiftRows(&state); MixColumns(&state); AddRoundKey(&state, &(ctx->round_keys[round * AES_BLOCKLEN])); } // 4. 第10轮(最后一轮),无MixColumns SubBytes(&state); ShiftRows(&state); AddRoundKey(&state, &(ctx->round_keys[round * AES_BLOCKLEN])); // round此时为10 // 5. 将状态矩阵拷贝到输出(列优先) for (int i = 0; i < 4; ++i) { for (int j = 0; j < 4; ++j) { output[i + 4 * j] = state[i][j]; } } }4.3 解密主函数(InvCipher)
解密是加密的逆过程,步骤顺序相反,且每一步都是其逆操作。注意,由于列混合的逆操作InvMixColumns更复杂,有一种优化策略叫“等效解密”,但为了清晰理解,我们先实现标准逆运算。
void AES_DecryptBlock(AES_ctx *ctx, const uint8_t *input, uint8_t *output) { state_t state; uint8_t round = AES_NR; // 从最后一轮开始 // 1. 将输入密文拷贝到状态矩阵 for (int i = 0; i < 4; ++i) { for (int j = 0; j < 4; ++j) { state[i][j] = input[i + 4 * j]; } } // 2. 初始轮密钥加(使用最后一轮密钥) AddRoundKey(&state, &(ctx->round_keys[round * AES_BLOCKLEN])); round--; // 3. 前9轮完整逆运算 for (; round > 0; --round) { InvShiftRows(&state); InvSubBytes(&state); AddRoundKey(&state, &(ctx->round_keys[round * AES_BLOCKLEN])); InvMixColumns(&state); } // 4. 第1轮(最后一轮逆运算),无InvMixColumns InvShiftRows(&state); InvSubBytes(&state); AddRoundKey(&state, &(ctx->round_keys[0])); // 使用第0轮密钥(即原始扩展后的第一个轮密钥) // 5. 将状态矩阵拷贝到输出 for (int i = 0; i < 4; ++i) { for (int j = 0; j < 4; ++j) { output[i + 4 * j] = state[i][j]; } } }注意解密时轮密钥的使用顺序是反的。初始轮密钥加用的是第10轮密钥,然后依次递减。
4.4 初始化函数
我们需要一个函数来初始化上下文,主要是执行密钥扩展。
void AES_Init(AES_ctx *ctx, const uint8_t *key) { KeyExpansion(key, ctx->round_keys); }5. 工作模式与填充方案初步探讨
我们上面实现的是最基础的电子密码本(ECB)模式下的单块加解密。ECB模式简单,但有一个致命缺点:相同的明文块会被加密成相同的密文块。对于有重复模式的数据(如图像),这会泄露信息。因此,在实际应用中,我们不会直接使用ECB。
5.1 常用工作模式简介
为了加密超过一个块的数据,我们需要选择一种工作模式。常见的模式有:
- CBC(密码块链接):最常用的模式之一。每个明文块在加密前,先与前一个密文块进行异或。需要一个初始化向量(IV)来启动这个过程。IV不需要保密,但必须不可预测(通常随机生成)。CBC模式能提供更好的安全性。
- CTR(计数器):将计数器加密后与明文异或得到密文。它可以将分组密码转换为流密码,支持并行计算和随机访问,非常高效。
- GCM(伽罗瓦/计数器模式):在CTR基础上增加了消息认证功能(GMAC),能同时提供加密和完整性校验,是现代TLS协议中的宠儿。
实操心得:在资源允许的情况下,优先考虑使用CBC或CTR模式替代ECB。如果自己实现CBC,需要处理好IV的生成和传递,并且注意解密时错误的IV会导致第一个明文块解密错误,但后续块正确(错误传播特性)。
5.2 填充(Padding)
AES块大小是16字节。如果要加密的数据长度不是16的整数倍,就需要填充。最简单的填充方式是PKCS#7:缺n个字节,就填充n个值为n的字节。例如,如果最后一块缺3字节,就填充0x03 0x03 0x03。解密后,读取最后一个字节的值n,然后移除最后n个字节即可得到原始数据。
实现一个简单的PKCS#7填充和去填充函数是集成到完整加密流程中的必要步骤。
6. 测试、验证与性能考量
代码写完了,怎么知道它对不对?我们需要一个可靠的测试向量进行验证。
6.1 使用标准测试向量验证
NIST(美国国家标准与技术研究院)提供了官方的AES测试向量。我们可以找一个AES-128 ECB模式的测试用例来验证我们的实现。
例如,一个经典的测试向量:
- 密钥:
2b 7e 15 16 28 ae d2 a6 ab f7 15 88 09 cf 4f 3c - 明文:
32 43 f6 a8 88 5a 30 8d 31 31 98 a2 e0 37 07 34 - 密文:
39 25 84 1d 02 dc 09 fb dc 11 85 97 19 6a 0b 32
我们可以编写一个简单的测试函数,将密钥和明文输入我们的AES_EncryptBlock,然后逐字节比较输出是否与标准密文一致。解密测试同理。
6.2 常见问题与调试技巧
在实现过程中,我踩过不少坑,这里分享几个最常见的:
状态矩阵索引错误:这是最易出错的地方。务必牢记列优先的填充和读取顺序。一个快速检查方法是:用全零的明文和密钥加密,如果第一步
AddRoundKey后状态矩阵全零,说明映射可能错了(因为密钥扩展后第一轮密钥就是原密钥,全零异或全零还是全零)。密钥扩展错误:如果加解密结果不对,但单步跟踪加密过程发现第一轮之后状态值就与预期不符,很可能是密钥扩展错了。重点检查
RotWord、SubWord和Rcon异或的顺序和实现,特别是Rcon的索引i / AES_KEYLEN。逆算法不匹配:确保
InvSubBytes、InvShiftRows、InvMixColumns的实现完全正确。一个有效的验证方法是:随机生成一个状态矩阵,先进行SubBytes再进行InvSubBytes,看是否能恢复原值。对ShiftRows和MixColumns也做类似测试。字节序问题:我们的实现假设数据在内存中就是普通的字节数组。如果你的测试数据是从文件或网络中以十六进制字符串形式读入,需要正确转换。
性能优化:我们目前的实现是教育性质的,注重清晰。在实际追求性能的场景,可以考虑:
- 使用查表法:将
SubBytes、ShiftRows、MixColumns合并成基于T表的查找操作(如使用4个1KB的T表),这能极大提升速度,但会增加代码大小。 - 使用处理器指令集:现代x86/x64 CPU支持AES-NI指令集,ARMv8也有加密扩展指令。这些硬件加速比任何软件实现都快几个数量级。在支持的环境下,应优先使用这些指令。
- 使用查表法:将
6.3 集成到实际项目中的建议
当你确认自己的AES实现正确后,如果想集成到项目中,建议:
- 封装良好的API:提供类似
aes128_cbc_encrypt/decrypt这样的函数,内部处理好IV和填充。 - 注意内存安全:确保密钥等敏感数据在使用后及时从内存中清除(例如,使用
memset_s或类似函数)。 - 考虑使用经过审计的库:对于生产环境,除非有极特殊需求(如无libc的裸机环境),否则强烈建议使用久经考验的库,如mbed TLS、libsodium或OpenSSL。自己实现的密码学代码很难保证没有侧信道攻击等漏洞。
7. 从ECB扩展到CBC模式的实现示例
为了让你更好地理解如何将基础块加密应用于实际模式,我们简要勾勒一下CBC模式的加密和解密流程。假设我们已经有了一个可靠的、经过测试的AES_EncryptBlock和AES_DecryptBlock函数。
CBC加密流程:
- 将明文数据按16字节分块,最后一块可能需要PKCS#7填充。
- 准备一个16字节的随机初始化向量(IV)。
- 对于第一块明文,先与IV进行异或,然后进行AES加密,得到第一块密文。
- 对于后续的每一块明文,先与前一块得到的密文进行异或,然后再进行AES加密。
- 最终输出的密文由IV(需要与密文一起传输或存储)和所有加密后的块组成。
CBC解密流程:
- 读取IV和密文数据。
- 对于第一块密文,先进行AES解密,然后再与IV进行异或,得到第一块明文。
- 对于后续的每一块密文,先进行AES解密,然后再与前一块密文进行异或,得到明文。
- 移除最后一块的填充,得到原始数据。
这里有一个关键点:CBC解密的异或操作对象是密文,而不是解密后的中间结果。这是因为加密时的异或是在加密函数之前进行的。这种结构使得解密过程可以并行化(虽然我们这里的示例是串行的)。
实现CBC模式会让你对分组密码的工作模式有更深刻的认识。它有效地消除了ECB模式中相同明文产生相同密文的问题,因为每一块明文的加密都依赖于前一块的密文(或IV)。
8. 总结与资源推荐
从头实现AES是一次收获巨大的旅程。它强迫你关注每一个字节的流动,理解每一行代码背后的数学原理。虽然最终代码可能只有几百行,但其中蕴含的关于对称密码设计、有限域运算和代码优化的知识却非常深厚。
在调试通过自己的AES实现后,我强烈建议你做以下几件事来巩固和扩展:
- 尝试实现AES-192和AES-256:主要改动在于密钥扩展算法和轮数(分别为12轮和14轮)。这能帮你理解算法如何适应不同密钥长度。
- 实现CTR模式:相比CBC,CTR模式不需要填充,且可以并行加密/解密,实现起来是另一种有趣的体验。
- 阅读标准文档:FIPS PUB 197是AES的官方标准,虽然数学性较强,但对于理解算法设计初衷极有帮助。
- 分析优秀开源实现:去读一读Tiny-AES-c(一个极简的C语言AES实现)或者mbed TLS中AES模块的源代码,看看别人是如何组织代码、进行优化和保证安全的。
最后,记住密码学是一个“魔鬼在细节中”的领域。自己实现的代码用于学习和理解无妨,但在关乎真实数据安全的生产环境中,请务必依赖那些经过广泛审查和测试的权威密码学库。把轮子造一遍是为了知道车为什么能跑,而不是为了在所有路上都用自己的轮子。