AES-128加密算法核心原理与工程落地避坑指南
简介这是一份面向STM32F103的AES128 ECB模式加密实现代码包适合嵌入式开发者或需要为单片机项目加入数据加密功能的学习者。资源内包含1个C文件与1个头文件共2个文件压缩包仅5KB结构精简便于直接阅读和移植。代码主要涵盖AES模块初始化、128位密钥设置、数据分块及加密函数调用等核心环节并体现了在STM32F103上使用内置硬件加速器的高效处理思路可直接集成到Keil工程中使用。已有355人学习/下载对于想快速理解AES128在Cortex-M3平台落地方式的人是一份简洁实用的参考实现也适合在此基础上扩展CBC等更安全的加密模式。1. 一个 16 字节的密钥凭什么让 AES-128 在低功耗设备上继续存活对接过老旧工业设备的人都有这种体验对方只认 AES-128密钥长度写死成 16 字节协议文档里连密钥扩展的中间值都给你列好了。你心里犯嘀咕AES-256 都出多少年了怎么还在用 128但真把代码跑起来才发现AES-128 不是低配它是在芯片算力、内存占用和安全性三者之间卡得最准的一档。 AES 加密 的核心是分组迭代128 位密钥对应 10 轮运算单轮延迟低到可以在几十 MHz 的单片机上实时处理而 256 位要多出整整 4 轮功耗和时延差距在低功耗场景里直接决定方案能不能立项。这篇笔记就把 AES-128 从轮函数拆到工程落地讲清楚每一轮在做什么、参数怎么选、哪些坑我替你先踩过。2. 拆轮函数AES-128 的 10 轮运算到底在保护哪三样东西2.1 从 16 字节明文块到状态矩阵分组运算的第一步AES 是分组密码AES-128 的分组长度固定为 128 位也就是 16 个字节。输入的明文不管多长都要先切成 16 字节一块最后一块不满 16 字节时用填充补齐。这 16 字节不是按一维数组处理的而是按列优先排成一个 4x4 的字节矩阵叫做状态矩阵。第 0 到 3 字节放在第一列第 4 到 7 字节放在第二列以此类推。所有轮运算——字节代换、行移位、列混淆、轮密钥加——都是在这个矩阵上反复操作。这个排列方向经常被忽略但它在实现层面很关键。你如果按行优先读入数据后续列混淆的矩阵乘法结果完全不同解密出来的东西必然是一堆乱码。我第一次手写 AES 教学实现时就在这儿翻过车输出对不上参考向量排查了半天才发现是字节序问题。工程上最常见的做法是直接用字节数组读入索引 i 对应 state[i % 4][i // 4]再按列读取。只要你始终保证加解密用同一套排列规则结果就不会错但和官方测试向量比对时必须按列优先来否则对不上。2.2 密钥扩展一条 16 字节主密钥如何变出 176 字节轮密钥AES-128 每轮需要一个 128 位的轮密钥加上初始的轮密钥加一共要用 11 个子密钥总长 176 字节。这 176 字节不能凭空生成它由 16 字节主密钥通过密钥扩展算法推导出来。扩展过程按 4 字节一字来算初始的 W[0] 到 W[3] 就是主密钥本身之后每四个字为一组第 i 个字等于第 i-4 个字异或第 i-1 个字特殊规则出现在每组第一个字上先把上一个字循环左移一个字节再逐字节过 S 盒最后异或轮常量 Rcon。轮常量是固定序列AES-128 只用到前 10 个值01, 02, 04, 08, 10, 20, 40, 80, 1B, 36。这个序列在 GF(2^8) 域上按 x 的多项式递推看不出规律但绝不能写错。很多移植到 MCU 上的简化实现为了省空间把 S 盒和 Rcon 都提前算好存成常量表运行时就查表这种做法在工程上完全可接受。def key_expansion_first_round(master_key): # master_key: 16 字节主密钥 # 只演示第一组扩展密钥的生成逻辑S 盒查表省略 sbox [0x00] * 256 # 实际应为 AES S 盒共 256 个字节 rcon 0x01 w [ int.from_bytes(master_key[0:4], big), int.from_bytes(master_key[4:8], big), int.from_bytes(master_key[8:12], big), int.from_bytes(master_key[12:16], big), ] # 对 w[3] 做 RotWord: 循环左移一个字节 temp ((w[3] 8) 0xffffffff) | (w[3] 24) # 对四个字节分别做 SubWord: 查 S 盒 temp (sbox[(temp 24) 0xff] 24) | \ (sbox[(temp 16) 0xff] 16) | \ (sbox[(temp 8) 0xff] 8) | \ (sbox[temp 0xff]) # 异或 Rcon只影响最高字节 w4 w[0] ^ temp ^ (rcon 24) return w4这段代码演示的是密钥扩展的第一轮计算实际应用中 S 盒是不可省略的查表完整的 S 盒有 256 个值FIPS-197 附录 A 里能直接抄。注意int.from_bytes用的是大端读取如果你的设备是小端 MCU主密钥字节序要先确认我见过不少设备端实现因为大小端没统一加密出来的数据和上位机对不上。Rcon 只异或到最高字节低三个字节不受轮常量影响这个细节在调试轮密钥时可以帮你快速定位是扩展逻辑错还是查找表错。2.3 四种变换为什么缺一不可混淆与扩散的工程意义AES 每一轮由四个变换组成它们各自承担不同的安全职责。字节代换 SubBytes 是唯一的非线性变换通过 S 盒完成目的是混淆一个字节的变化会扩散成整个状态矩阵的雪崩效应。行移位 ShiftRows 把矩阵的每一行按不同偏移循环左移让列与列之间的数据产生关联。列混淆 MixColumns 对每一列做矩阵乘法把单列内四个字节的值混合成新的四个字节。轮密钥加 AddRoundKey 则把扩展出的轮密钥逐字节异或到状态矩阵上。这三个变换的分工值得细品SubBytes 负责抵抗线性密码分析ShiftRows 和 MixColumns 负责扩散让明文的单个比特变化在若干轮后影响到所有输出比特。最后一轮特意省略了 MixColumns这是为了加解密结构对称如果你自己实现的时候在最后一轮多加了一次列混淆解密端就会错位。调试 AES 实现时每轮结束后对比中间状态值和参考实现最容易出错的地方就是这里。下表列出了各变换的作用和选型意义方便你移植时对照检查变换作用省略后果SubBytes非线性混淆抵抗线性与差分攻击整体退化成仿射变换密钥可被推导ShiftRows跨列扩散打破列间独立性每列独立加密退化为 4 个并行的小分组MixColumns列内混合放大雪崩效应单个字节变化不影响其他输出字节AddRoundKey引入密钥加密结果依赖主密钥整个算法变成固定的公开变换3. 用 cryptography 库跑通最小实现密钥、IV 与填充的三种写法3.1 最小可用代码一条命令装好依赖工程上实现 AES-128 首选现成的密码学库Python 生态里最常用的是cryptography它底层封装了 OpenSSL性能和安全实现都经过大量审计比手写靠谱得多。安装只需要一条命令pip install cryptography装完后先确认版本不同版本的 API 略有差异我在 3.x 和 42.x 上都跑过下面的代码接口没有变化但如果你用的是 2.x 老版本Cipher对象销毁时可能不会主动擦除敏感内存建议升级。这条命令做完就能开始写加解密逻辑了。3.2 ECB 与 CBC 的完整实现代码分组密码不能直接处理任意长度的明文得配合模式。最简单的是 ECB 模式相同明文块生成相同密文块效率高但会泄露明文结构真实项目中我基本只拿它做测试。CBC 模式让每个明文块先和上一个密文块异或再加密相同明文在不同位置会得到不同密文是传统模式下最常用的选择。from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives.padding import PKCS7 import os # AES-128 密钥长度固定为 16 字节 key bytes.fromhex(2b7e151628aed2a6abf7158809cf4f3c) # CBC 模式 IV 必须是 16 字节每次加密都应重新随机生成 iv os.urandom(16) # 1. 使用 PKCS7 填充块大小 128 位即 16 字节 padder PKCS7(128).padder() plaintext bAES-128 最小可运行示例 padded padder.update(plaintext) padder.finalize() # 2. 创建加密器并一次性处理完整数据 cipher Cipher(algorithms.AES(key), modes.CBC(iv)) encryptor cipher.encryptor() ciphertext encryptor.update(padded) encryptor.finalize() # 3. 解密注意 IV 必须和加密时相同 decryptor Cipher(algorithms.AES(key), modes.CBC(iv)).decryptor() unpadder PKCS7(128).unpadder() recovered unpadder.update( decryptor.update(ciphertext) decryptor.finalize() ) unpadder.finalize() assert recovered plaintext这段代码里有两个参数直接决定成败。第一个是PKCS7(128)的 128它表示填充对齐的块大小单位是比特必须和 AES 分组大小一致写成 128 而不是 16 是因为 API 的形参定义按位来算。第二个是 IV 的生成方式os.urandom(16)每次产生不同的随机字节CBC 模式里 IV 不需要保密但必须保证每次加密都用新的否则两个设备加密相同明文会得到完全相同的密文开头攻击者能据此推断出明文前缀。解密时 IV 必须和加密时一致通常的做法是把 IV 拼在密文头部一起传输接收方取出前 16 字节当 IV剩下的是密文。3.3 脱离库的纯 Python 教学实现值得写吗如果你只是想跑通业务上面这段代码就够了。但如果你在设备端做移植手头没有 OpenSSL那就得自己实现 AES 核心算法。纯 Python 写 AES 性能很差纯软件跑 1MB 数据可能要几十秒只能用来验证算法流程不能上生产。我一般建议这么做先用库实现跑通业务逻辑同时写一份纯 Python 参考实现对照官方测试向量确认核心算法理解正确再移植到 C 或汇编。这样即使最终目标是嵌入式Python 参考实现也能当调试工具帮你在 Cross-compile 环境里快速定位是算法问题还是平台问题。纯 Python 实现里最需要注意的就是解密时的逆变换顺序。解密不是把加密的四个变换倒过来做一遍那么简单因为列混淆的逆矩阵和轮密钥加的顺序有微妙差别解密时轮密钥加要先生成解密轮密钥。这些细节手写很容易错所以务必用 FIPS-197 的测试向量在每一轮中间结果上做断点比对不要等全部跑完看最终结果对不对再回头找那会浪费大量时间。4. 选错模式等于没加密CBC、CTR、GCM 的参数怎么定4.1 五种操作模式对比什么时候该用哪个AES 加密本身只定义了单块加密如何把任意长度的明文串起来加密取决于操作模式。选模式不是看哪个流行而是看你的数据形态和是否有认证需求。ECB 模式最简单每块独立加密可以并行处理但明文的重复模式会直接暴露在密文里比如一张纯色图片用 ECB 加密后轮廓依然清晰可见这就是为什么它只适合做测试向量比对。CBC 模式通过链接前一块密文打破了这种模式泄露但加密过程是串行的无法并行。CTR 模式把计数器加密后与明文异或本质上是把 AES 变成流密码可以并行、可以随机访问任意密文块非常适合磁盘加密和网络包加密。GCM 在 CTR 基础上增加了 GMAC 认证标签密文被篡改时接收方能立刻发现。下表把五个常用模式的参数和适用场景列了出来照这个选基本不会出大错模式IV/Nonce 长度是否认证并行支持适用场景ECB无否完全并行单块加密、测试向量验证CBC16 字节否仅解密可并行文件加密、通用数据加密CTR16 字节否加解密均可并行流式数据、随机访问GCM12 字节推荐是加解密均可并行网络协议、需要防篡改的场景CFB16 字节否解密可并行流式传输、长度不固定场景4.2 IV 的生成规则不随机、不复用的代价IV 的生成规则取决于模式但有一条铁律同一个密钥下IV 绝不能复用。CBC 模式里 IV 重用时两个相同明文块的异或结果会暴露明文关系CTR 模式里 IV 重用的后果更严重两个密文流异或后直接等于两个明文流异或明文信息几乎完全泄露。GCM 模式对 IV 复用尤其敏感攻击者拿到两个用同密钥同 IV 加密的消息可以直接伪造认证标签。我见过一个真实的翻车案例团队为了省事把设备 MAC 地址当成 IV 用。MAC 地址是公开的、固定不变的攻击者只要知道设备型号就能推算出 IV等于每台设备的 CBC 加密都在用可预测的固定 IV。正确做法是每次加密调用os.urandom(16)生成随机 IV或者用单调递增的计数器并保证断电重启后从持久化存储恢复绝不能偷懒写死。4.3 填充方式与长度边界PKCS7 之外的三种选择分组密码要求明文长度是 16 字节的整数倍不满足就得填充。PKCS7 是最常见的方案缺几个字节就填几比如差 3 个字节就填三个 0x03。这种方法的好处是填充字节自带长度信息解密时可以准确去掉填充。但它要求至少补一个字节即使明文恰好是 16 的整数倍也要额外补满 16 个字节很多人第一次看到密文比明文长 16 字节时会以为是 bug。另一种叫 ZeroPadding缺的字节全部补 0x00实现简单但有个致命的边界问题如果明文末尾本身有 0x00解密时无法区分哪些 0 是填充、哪些是原始数据只能配合已知长度的协议使用。ISO 10126 现在基本被淘汰了不再推荐。如果你的协议已经明确长度字段可以直接用 NoPadding也就是不填充密文和明文等长但上层必须保证每次传入的数据都正好是 16 字节倍数否则直接报错。选填充方式时先问清楚对端协议不是所有系统都认 PKCS7嵌入式老设备上 Ive seen ZeroPadding 用到现在的也不少。注意CBC 模式下填充必须在加密前完成解密后必须先校验填充字节再去除直接忽略填充校验会让密文可被恶意构造这就是著名的 padding oracle 攻击的入口。5. AES-128 落地避坑五个让我重写接口的血泪教训5.1 密钥硬编码在代码里上线两周被同事当场抓包现象设备端和服务器约定好一个固定的 16 字节密钥直接写在源码里代码仓库是内部私有的团队觉得没问题。结果有次源码泄露到了合作方手里所有设备的密文都能被解开整个项目被迫重新换密钥、升级固件。原因密钥属于机密参数它和代码的安全边界完全不同。代码泄露顶多暴露逻辑密钥泄露等于所有历史数据全部失效。硬编码的密钥无法轮换一旦泄露只能报废重来而且 Git 历史里删掉也没用旧 commit 里依然能翻出来。解决密钥不进代码库通过环境变量、配置中心或硬件安全模块注入。设备端优先用安全芯片的密钥槽位没有安全芯片的先把密钥密文存在独立分区启动时解密加载。我给团队的硬性要求是源码里出现key ...这种写法代码评审直接打回。5.2 CBC 模式用了固定 IV两台设备密文完全相同现象设备 A 和设备 B 发送同样的上报数据抓包发现密文完全一致。攻击者不需要解密直接从密文相等就能判断两端状态相同这在智能电表场景里直接泄露了用户的用电模式。原因开发为了省事在配置文件中写死了一个 IV所有设备共用。CBC 模式下第一个明文块先和 IV 异或IV 相同则第一个密文块相同后续所有块都跟着相同整个密文完全没区分度。解决每设备、每次会话生成独立随机 IVIV 明文传输即可因为 IV 本身不保密——保密的是密钥。把 IV 拼在密文前发出去接收方取前 16 字节当 IV后面的字节当密文处理。这是我反复强调的一条加密接口的文档里必须写清楚 IV 的生成时机和传输位置。5.3 从口令生成密钥只做了一次哈希字典攻击全破现象需求是用户输入一串口令设备用这串口令的 MD5 直接当 AES-128 密钥。测试阶段一切正常上线后安全测试发现弱口令很容易被 MD5 彩虹表命中排列组合里常见的口令直接逆推出密钥。原因用户口令的熵通常远低于 128 位“123456”这类口令的 MD5 是公开可查的等于密钥就写在字典里。直接用原始口令当密钥也不可行因为 AES-128 要求密钥正好 16 字节口令长度几乎不可能精准匹配。解决用密钥派生函数把口令转换成密钥推荐 PBKDF2 或 scrypt。PBKDF2 至少迭代 100000 次加随机盐输出 16 字节作为 AES-128 密钥。要注意盐不需要保密但每个用户必须不同否则相同口令会导出相同密钥等于白做了。5.4 用字符串长度对明文截断解密报错报了三天现象明文里包含中文和数字混合内容加密用的 UTF-8 编码长度判断却用了字符串的len()也就是字符数。一条“温度25.5℃”的字符串字符数 7UTF-8 字节数可能是 10 或 12结果截断位置不对解密出来数据全是乱码填充校验直接失败。原因AES 处理的是字节不是字符。凡是涉及编码转换的语言都必须明确在加密前把字符串转成字节数组在解密后把字节数组转回字符串中间任何一步用字符数做长度计算都会错位。解决加密入口统一走字节数组接口明文一律encode(utf-8)密文拿到手先当字节处理解密后再decode(utf-8)。代码里所有长度判断都用len(byte_data)不允许出现len(str_data)参与分组切分的写法。这个坑和 AES 本身没关系但排起错来最折磨人因为报错信息要么是 padding 错误要么是编码异常指向性很弱。5.5 只加密不认证传输数据被人改了都不知道现象加密链路做好了但设备端只用了 CBC 模式加密没有附带任何完整性校验。测试时模拟中间人篡改密文的一个字节解密端照样能解出一段乱码而且程序不会报错直接把乱码当业务数据处理了。原因加密保证的是机密性不保证完整性。攻击者篡改密文后解密结果会变但算法本身不会主动发现。很多协议只加密不认证导致数据在传输过程中被静默修改轻则业务异常重则被利用做注入攻击。解决升级到 GCM 模式它自带认证标签解密时先验标签篡改任何一位都会导致认证失败直接拒绝数据。如果协议强制用 CBC就必须额外加 HMAC先用 HMAC 计算密文的摘要再对摘要做比对验证顺序是先行后加密。记住一个原则先认证再解密顺序反了会引入解密预言攻击。6. 用 FIPS 197 测试向量验证你的实现一条命令见分晓6.1 官方测试向量长什么样——我现在也存了一份在笔记里FIPS-197 附录 B 给出了 AES-128 的完整测试向量包含主密钥、明文、以及每一轮加密后的中间状态。我最常用来做冒烟测试的是第一个向量密钥为2b7e151628aed2a6abf7158809cf4f3c明文为6bc1bee22e409f96e93d7e117393172aECB 模式下加密结果应为3ad77bb40d7a3660a89ecaf32466ef97。这组数据我几乎每次都手输一遍因为它的长度正好是 16 字节适合快速验证。还有一组 CBC 模式测试向量常被忽略同一个密钥下IV000102030405060708090a0b0c0d0e0f明文6bc1bee22e409f96e93d7e117393172a加密结果为7649abac8119b246cee98e9b12e9197d。如果你的实现能对上这两组输出核心算法基本没问题。6.2 用 openssl 与 Python 对照验证的三步检查我现在的验证流程分三步。第一步用 openssl 命令行直接跑 ECB 无填充模式确认已知向量能对上第二步用 Python 的 cryptography 库跑同样数据确认库的行为和 openssl 一致第三步如果设备端有自研实现把中间轮的状态值逐轮打印和 FIPS-197 附录 B 里的中间值比对哪一轮开始不一致就锁哪一轮的代码。# ECB 无填充模式key 和明文均为十六进制 echo -n 6bc1bee22e409f96e93d7e117393172a | xxd -r -p \ | openssl enc -aes-128-ecb -e -K 2b7e151628aed2a6abf7158809cf4f3c -nopad \ | xxd -pfrom cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes key bytes.fromhex(2b7e151628aed2a6abf7158809cf4f3c) plaintext bytes.fromhex(6bc1bee22e409f96e93d7e117393172a) cipher Cipher(algorithms.AES(key), modes.ECB()) encryptor cipher.encryptor() ciphertext encryptor.update(plaintext) encryptor.finalize() print(ciphertext.hex())两条命令的输出都应该是3ad77bb40d7a3660a89ecaf32466ef97。xxd 的-r -p把十六进制文本还原成二进制openssl 的-nopad关闭填充直接加密xxd -p把结果打印成十六进制。这套组合命令在嵌入式移植时能当金标准用——设备端算出的结果和它不一致不用怀疑工具先检查自己的实现。我在自研算法模块时把 FIPS-197 的测试向量写成了一个独立的测试脚本每次改动核心代码都跑一遍全量向量而不是只跑自己熟悉的那组数据。经验是只测一组向量能覆盖大部分逻辑错误但边界字节序的问题往往要到第二、第三组向量才暴露。特别是轮常量、S 盒的末位索引这类细节第一组向量全对不代表全部对多测几组才是真的稳妥。这套验证流程帮我在正式项目里少返工了好几次现在就当作固定习惯每次加密模块改动后先跑向量再提交。希望帮到你。本文还有配套的精品资源点击获取