ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

C语言实现AES算法:ECB/CBC/CFB/CTR四种模式与密钥扩展实战

C语言实现AES算法:ECB/CBC/CFB/CTR四种模式与密钥扩展实战 简介一套完整的AESECB、CBC、CFB、CTR加密/解密C语言实现覆盖AES-128、192、256三种密钥长度适用于金融POS安全认证、嵌入式安全模块、网络通信加密等对数据保密性有严格要求的场景。压缩包共6个文件以3个C源码文件为核心配套2个头文件与1个Makefile整体仅16KB工程结构非常精简源码按算法模块划分包含密钥扩展、分组加解密以及四种工作模式的完整逻辑头文件对外暴露简洁的调用接口Makefile则免去手动配置编译参数的麻烦。资源自带测试程序在Linux环境下进入目录执行make即可编译已在Ubuntu 16.04上验证通过可快速验证各模式加解密结果与标准测试向量是否一致。目前已有5316人学习下载适合信息安全、嵌入式及底层开发人员直接参考或二次移植可显著降低从零实现AES的难度与时间成本。 搞嵌入式安全通信的人迟早会碰到AES。我最近在一个MCU项目里做固件升级包的对称加密需要在资源受限的环境下用C语言实现AES算法支持ECB、CBC、CFB、CTR四种工作模式密钥长度覆盖128、192、256三种。整个模块从设计、编码到调通踩了不少坑今天把思路和代码骨架整理出来给同样需要在C项目里落地AES的朋友做个参考。这篇内容适合两类人一类是刚接触AES、只听说过名字但不知道分组模式和密钥扩展怎么玩的初学者另一类是已经有了一个可用的AES核心但不确定该怎么封装成多模式接口的工程师。无论你是纯软件实现还是打算移植到STM32这类单片机上思路和注意事项都是通用的。1. AES算法拆解从字节操作到轮函数1.1 状态矩阵与四个基本操作AES不是面向比特的算法它操作的是一个4乘4的字节矩阵叫做状态。输入数据按列优先填入这个矩阵然后每一轮都在这个状态上做四件事字节代换、行位移、列混合、轮密钥加。这四件事理解起来都有点绕但落到C语言里其实非常机械。字节代换就是用S盒查表替换每个字节S盒是一个256字节的静态数组属于AES标准定义好的常量。行位移是把矩阵的第1行循环左移1字节第2行左移2字节第3行左移3字节第0行不动。列混合是每一列做一个有固定矩阵的有限域乘法这个乘法是在GF(2^8)上进行的不是普通的整数乘法。轮密钥加就是把状态和本轮的子密钥逐字节异或实现起来就是一个循环异或。解密过程就是这四个操作的逆过程注意顺序也要反过来并且列混合逆变化和行位移逆位移的参数要和加密对应好。很多人写AES容易在解密的轮变换顺序上翻车我的经验是不要试图优化顺序严格按照FIPS-197的伪代码来先把功能跑通再考虑性能。1.2 四种模式加密粒度与错误传播AES核心算法一次只能处理16字节也就是一个128位块。实际通信中的数据往往不止16字节所以需要定义“怎么把数据分成一块一块块与块之间怎么关联”这就是工作模式。我实现的是最常见的四种模式是否填充是否可以并行错误传播范围典型用途ECB需要可以单块独立错误不传播不适合协议数据仅适合单块加解密或测试CBC需要加密不可并行解密可并行当前块错误会影响下一块文件加密、固件加密、磁盘加密CFB不需要加密不可并行解密可并行错误传播一个块加一个字节流式通信、低延时光盘数据CTR不需要可以并行错误只影响当前块高速通信、磁盘扇区加密、GCM的基础ECB模式最简单每个明文块独立加密同样内容的明文块加密后结果相同缺乏语义安全性正规协议里基本不建议使用但很多老项目还在用所以我也保留了。CBC模式是ECB的增强加密前先跟上一块密文异或第一个块跟IV异或。CFB和CTR模式本质上是把AES当成密钥流生成器加密和解密都用同一个AES加密函数省去了解密函数的调用这在内存紧张的嵌入式平台上很实用。关键点是CFB和CTR不需要填充因为它们处理的是任意长度的字节流最后一轮只取需要的密钥流长度即可。而ECB和CBC因为是对块加密最后一块不足16字节时必须先填充。这个差异是后面代码设计的分水岭。2. C语言实现前的准备轮密钥、填充、数据接口2.1 密钥长度与轮数Nk、Nr、Nb的关系AES支持128、192、256位三种密钥对应16、24、32字节。代码里通常用四个符号描述参数Nb4固定表示状态矩阵列数Nk密钥长度除以32Nr轮数。它们的关系是AES-128Nk4Nr10AES-192Nk6Nr12AES-256Nk8Nr14轮密钥扩展就是从原始密钥生成Nk字节到(Nr1)*16字节的扩展密钥。每一轮的轮密钥加都要用这16字节。我最初的实现一上来就把三种密钥长度全部用条件分支塞进同一个函数结果代码臃肿且容易出错。后来改成用一个上下文结构体保存Nk、Nr、扩展密钥数组统一起来就清晰多了。密钥扩展的核心思路是扩展密钥数组看成一个个4字节字初始的字直接来自密钥后续每个字是前一个字和Nk位置之前字的异或每Nk字触发一次S盒变换并异或轮常数。轮常数是一个固定字节数组用来破坏对称性。这部分我在3.1节给出可运行代码。2.2 数据填充与模式选择PKCS#7 vs 零填充ECB和CBC的填充方式也必须提前定好。最通用的是PKCS#7填充如果最后一组还差n字节就补n个值为n的字节。比如还差5字节就补5个0x05完整多出16字节的倍数时也必须额外填充一个完整的16字节块否则接收端无法区分原始数据是否刚好对齐。类似地解密后要检查最后一个字节的值是否合法这个是接收端最容易忽略的地方。CBC模式的解密还需要处理好IV。IV长度固定为16字节解密方必须用和加密时相同的IV否则第一个块的明文会整体出错。我在项目里遇到过好几次密钥和加密数据都对但解密出来的前16字节乱码最后发现是IV传错了。推荐的做法是IV要么写入密文头部要么通过密钥协商单独下发千万不要硬编码在固件里。CFB和CTR不需要填充但它们也有一个隐含坑加密端和解密端的计数器必须严格同步。CTR模式常用16字节计数块前4字节可以是随机Nonce后12字节为递增计数器所有平台都必须用大端序编码计数器否则不同架构的MCU对不上。2.3 对外接口设计统一加解密函数我最终设计的对外接口很简单一个上下文结构体 三个函数typedef struct { uint8_t key[32]; // 原始密钥 uint8_t round_keys[240]; // 扩展密钥最多60字*4字节 uint8_t iv[16]; // 初始向量/计数器 uint8_t mode; // AES_MODE_ECB/CBC/CFB/CTR uint8_t key_len; // 16/24/32 } aes_ctx_t; void aes_init(aes_ctx_t *ctx, const uint8_t *key, int key_len, const uint8_t *iv, uint8_t mode); void aes_encrypt_block(aes_ctx_t *ctx, const uint8_t in[16], uint8_t out[16]); void aes_crypt(aes_ctx_t *ctx, const uint8_t *in, uint8_t *out, int len, int is_decrypt);aes_encrypt_block是核心单块加密除了ECB解密需要对应aes_decrypt_block之外CFB和CTR模式的解密也只需要调用aes_encrypt_block生成密钥流再异或。aes_crypt负责模式调度和填充逻辑这样上层业务代码只需要关心数据长度不需要知道分组模式的内部细节。3. 核心代码实现与模式实现细节3.1 AES-128/192/256核心加解密代码先给出一个精简但可运行的AES核心实现。这个实现参考了FIPS-197标准伪代码和开源项目tiny-AES-c的结构我用它做过LPC1768和STM32的移植只要修改字节序相关代码即可。S盒我直接用开源标准定义好的值实际工程里建议放在静态只读区static const uint8_t sbox[256] { 0x63,0x7c,0x77,0x7b,0xf2,0x6b,0x6f,0xc5,0x30,0x01,0x67,0x2b,0xfe,0xd7,0xab,0x76, // ... 请从FIPS-197附录A抄完整表 }; static const uint8_t rcon[10] { 0x01,0x02,0x04,0x08,0x10,0x20,0x40,0x80,0x1b,0x36 };轮密钥扩展void key_expansion(uint8_t *round_keys, const uint8_t *key, int nk, int nr) { for (int i 0; i nk; i) { ((uint32_t *)round_keys)[i] ((uint32_t *)key)[i]; } for (int i nk; i 4 * (nr 1); i) { uint32_t temp ((uint32_t *)round_keys)[i - 1]; if (i % nk 0) { temp sub_word(rot_word(temp)) ^ (rcon[i / nk - 1] 24); } else if (nk 6 i % nk 4) { temp sub_word(temp); } ((uint32_t *)round_keys)[i] ((uint32_t *)round_keys)[i - nk] ^ temp; } }单块加密void aes_encrypt_block(uint8_t *state, const uint8_t *round_keys, int nr) { add_round_key(state, round_keys, 0); for (int round 1; round nr; round) { sub_bytes(state); shift_rows(state); mix_columns(state); add_round_key(state, round_keys, round * 16); } sub_bytes(state); shift_rows(state); add_round_key(state, round_keys, nr * 16); }解密时把里面的mix_columns换成inv_mix_columnssub_bytes换成inv_sub_bytesshift_rows对应的逆移位也要实现。完整的S盒逆表、列混合的GF(2^8)乘法函数这里不贴了网上很容易找到但注意一定要逐个函数对照测试用例验证。3.2 四种模式的状态机实现有了单块加密函数四种模式的封装就简单了。下面是我最终采用的骨架ECB模式void aes_ecb_crypt(aes_ctx_t *ctx, const uint8_t *in, uint8_t *out, int len, int is_decrypt) { int blocks len / 16; for (int i 0; i blocks; i) { if (is_decrypt) aes_decrypt_block(in i * 16, out i * 16); else aes_encrypt_block(in i * 16, out i * 16); } // 填充校验或剥离由上层处理 }CBC模式void aes_cbc_crypt(aes_ctx_t *ctx, const uint8_t *in, uint8_t *out, int len, int is_decrypt) { if (is_decrypt) { uint8_t prev[16]; memcpy(prev, ctx-iv, 16); for (int i 0; i len / 16; i) { uint8_t block[16]; aes_decrypt_block(in i * 16, block); for (int j 0; j 16; j) out[i * 16 j] block[j] ^ prev[j]; memcpy(prev, in i * 16, 16); } } else { uint8_t prev[16]; memcpy(prev, ctx-iv, 16); for (int i 0; i len / 16; i) { uint8_t block[16]; for (int j 0; j 16; j) block[j] in[i * 16 j] ^ prev[j]; aes_encrypt_block(block, out i * 16); memcpy(prev, out i * 16, 16); } } }CFB模式void aes_cfb_crypt(aes_ctx_t *ctx, const uint8_t *in, uint8_t *out, int len, int is_decrypt) { uint8_t feedback[16]; memcpy(feedback, ctx-iv, 16); for (int i 0; i len / 16; i) { uint8_t keystream[16]; aes_encrypt_block(feedback, keystream); for (int j 0; j 16; j) { out[i * 16 j] in[i * 16 j] ^ keystream[j]; } // 注意加密和解密都是反馈密文不是反馈明文 memcpy(feedback, out i * 16, 16); } // 最后一组如果不足16字节同样先用AES加密反馈得到密钥流异或剩余字节即可 int remain len % 16; if (remain) { // 处理剩余部分与上面类似只是循环次数少一次 } }CTR模式void aes_ctr_crypt(aes_ctx_t *ctx, const uint8_t *in, uint8_t *out, int len) { uint8_t counter[16]; memcpy(counter, ctx-iv, 16); for (int i 0; i * 16 len; i) { uint8_t keystream[16]; aes_encrypt_block(counter, keystream); int chunk (len - i * 16 16) ? 16 : (len - i * 16); for (int j 0; j chunk; j) { out[i * 16 j] in[i * 16 j] ^ keystream[j]; } // 计数器递增大端序 for (int j 15; j 0; j--) { if (counter[j] ! 0) break; } } }这个CFB版本我在最后一次生成密钥流时没有复用缓冲区是为了确保最后一组长度的处理不会越界。CFB模式加密和解密都要把当前块密文存入反馈缓冲区如果误用明文做反馈解密结果会全错。4. 踩坑记录与调试经验4.1 密钥长度14字节的InvalidKeyException看到热词里有人问java.security.InvalidKeyException: Invalid AES key length: 14 bytes这几乎是跨语言对接时的高频问题。C语言里uint8_t key[32]只是开了一块内存但AES标准要求密钥长度必须是16、24、32字节。我在一个项目里用Java生成14字节的密钥其实那是把密码字符串直接当密钥传了正确做法是先用哈希或KDF把任意长度口令扩展到标准长度。C端也要校验接收到的密钥字节数否则memcpy越界或者后续密钥扩展计算读到未初始化内存一点都不安全。建议在初始化函数里增加长度判断if (key_len ! 16 key_len ! 24 key_len ! 32) { return AES_ERR_KEY_LEN; }这个检查一定要做不要依赖外部保证。嵌入式里最烦的bug就是传入非法长度后密钥扩展函数跑飞。4.2 解密后前16字节乱码或尾部报错CBC解密后前面16字节乱码九成是IV不对。我在自己代码里调试过一个问题加密端和解密端用的IV都是同一个静态数组但加密过程中我记得修改了IV数组内容导致解密初始化时读到了被改过的IV结果第一个块全乱。后来统一设计成aes_init内部深拷贝IV到上下文外部接口只负责传入原始IV不在模式函数里改动上下文里的IV值问题才彻底消失。另一个困扰是PKCS#7填充剥离。接收端拿到解密后的明文要先检查最后一个字节的值是否在1到16之间然后确认最后那n个字节是否真的都是n否则就是数据被篡改了。如果只检查尾部是否越界很可能被构造出错误填充导致解密异常。这一部分建议单独写个函数别和业务逻辑混在一起。4.3 嵌入式平台上用CFB/CTR省去填充的陷阱CFB和CTR确实不需要填充但有一个隐含问题它们一次只处理16字节的倍数最后一组不足16字节时处理代码不能简单跳过。我在CFB实现里如果剩余字节不为0需要先用上一块的密文和AES加密生成16字节密钥流再异或剩余数据。这上面特别容易踩的坑是缓冲区越界剩余字节可能少于16但aes_encrypt_block仍然需要16字节输入反馈所以额外开辟一个16字节临时数组是值得的不要图省事直接用栈上指针。CTR模式还要注意计数器溢出和回绕的问题。大文件超过2^32个16字节块后计数器低位会回绕必须由上层协议约定好如何处理否则密钥流会重复。另外如果所有分区都用同一个Nonce从头计数那么不同分区的密文流可能被攻击者做重放攻击。安全要求高的场景Nonce最好每次随机。4.4 性能优化查表法还是位运算AES的列混合部分用GF(2^8)乘法这个运算如果每次都算移位和异或在MCU上会很慢。更常见的做法是用预计算的乘法表如gmul2、gmul3查表或者直接使用T-Tables优化把SubBytes和MixColumns合并成四个大表。我实测过在72MHz的ARM Cortex-M3上纯字节操作的AES-128加密速度大约只有几十KB/s换用查表法后能提升一个数量级。但查表法有两个代价一是ROM占用T-Tables大约8KB很多小型MCU的Flash紧张二是查表时如果访问索引是外部可控的理论上存在缓存时序攻击的风险。对于固件升级、系统密文这种不确定实时攻击面的场景一般问题不大如果做对外的加密协议通信建议直接用硬件AES或mbedTLS的常量时间实现。更进一步我用单片机硬件AES外设的体会是别小看硬件加速。STM32的AES硬件模块虽然只支持ECB/CBC但速度非常可观而且不占CPU。CFB/CTR这种流模式可以自己用硬件AES加密16字节计数器再异或用户数据也算曲线救国。4.5 测试与验证先用标准向量再过业务最后一条也是我每写一个加密模块都要强调的不要拿业务数据直接当测试用例。先用NIST提供的AES测试向量做最基础的验证。比如AES-128密钥000102...0f明文00112233...ff加密后的密文是69c4e0d86a7b0430d8cdb78070b4c55a。把这段测试向量跑到C代码里如果结果不对说明核心轮函数有问题。然后再分别用ECB和CBC的NIST示例数据验证每种模式。连我这种老手都犯过把S盒抄错导致中间轮结果偶尔错位的错直接用业务数据排查会分不清是填充、IV还是模式问题。等核心算法和模式都通过测试向量后再对接协议数据用双向加密解密互测收尾。这样做能节省几天的调试时间。结尾的一点个人体会这几年代码越写越多我越来越觉得AES这种成熟算法能用现成稳定库就用现成库尤其是mbedTLS这种经过大量审计的比自己手写重轮函数安全得多。手写实现的动力无非是嵌入式资源极紧、需要绕过大型依赖或者想彻底搞懂算法原理。如果你也是后者建议按“测试向量→单块接口→模式封装→协议对接”的顺序来每一步都留好验证点别急着把所有代码一次性写完。做纯软件实现时记得把数组越界、长度检查和填充校验这些基础防护做扎实密码学代码的严谨程度是要高于普通业务代码的。希望这篇分享能让你少走几个我走过的弯路。本文还有配套的精品资源点击获取
返回列表