十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

STM32WL33x AES-GCM实战:硬件加密密文与Tag生成全流程

STM32WL33x AES-GCM实战:硬件加密密文与Tag生成全流程 从 STM32WL33x 上跑 AES-GCM 开始你大概率遇到过这种怪事HAL_CRYP_Encrypt 调用返回 HAL_OK密文也出来了但用 Python 或 OpenSSL 一解全是乱码Tag 就更不用说了怎么比对都对不上。这篇文章就把 STM32WL33x 的 AES 外设在 GCM 模式下产生有效 ciphertext 和 16 字节 tag 的完整流程讲透重点说清楚寄存器阶段切换、字节序配置、AAD 处理这几个最容易踩坑的环节。无论你现在是用 CubeMX 生成的 HAL 代码还是打算直接操作寄存器照着这条路走都能调通。这套方法适合谁正好卡在“硬件加密结果和软件参考实现不一致”的嵌入式开发者也适合第一次在 STM32 无线 SoC 上做认证加密的朋友。看完你不仅能修好自己的代码还能掌握一套通用的验证思路芯片输出的密文和 Tag必须能和 OpenSSL、Python cryptography 这类标准实现逐字节对上对不上就先查字节序再查阶段机问题基本迎刃而解。1. 先弄清楚 GCM 到底在算什么东西1.1 Ciphertext、Tag、AAD 三者的关系AES-GCM 是典型的 AEAD 算法也就是“加密同时认证”。它内部混合了两种机制一部分是 AES-CTR 模式的流加密负责把明文变成密文另一部分是 GHASH 这个多项式哈希负责生成认证标签 Tag。Tag 的作用是让接收方确认密文没有被篡改同时确认密钥和 nonce 正确。这里有个初学者特别容易搞混的点AADAdditional Authenticated Data附加认证数据和 Tag 的关系。AAD 是那些“不需要加密但必须防篡改”的字段比如协议头、地址、帧计数。AAD 不参与加密所以它不会出现在密文里但它的每一个字节都会参与 GHASH 运算最终影响 Tag。所以你在单片机上算 Tag必须把 AAD 原封不动地送进 AES 外设漏一个字节、错一个字节Tag 就不对。GHASH 的细节不展开你只需要记住一个直观比喻Tag 相当于给“AAD 密文”算出来的一串校验指纹但指纹不是简单的 CRC而是基于 AES 加密后的一个密钥流派生出来的。这个密钥流里面的第一个块算法称之为 J0。GCM 的 nonce也就是 IV经过特定处理后得到 J0J0 一方面用来加密 Tag另一方面充当计数器模式的起点。1.2 STM32WL33x AES 外设的 GCM 阶段机STM32WL33x 集成的 AES 外设和 STM32L4/WL 系列属于同一套设计思路它不像软件库那样一步到位给你算完而是把 GCM 分成了四个阶段由 AES_CR 寄存器的 GCMPH 字段控制00 初始化阶段写密钥和 IVJ0硬件生成 GHASH 子密钥 H。01 AAD 阶段把 AAD 数据一块一块喂进去硬件累加 GHASH 状态。10 数据阶段写入明文读出密文加密时或写入密文读出明文解密时同时继续更新 GHASH 状态。11 最终阶段硬件把所有计算结果汇合输出最终的 16 字节 Tag。为什么硬件要搞出这么繁琐的阶段机因为 GCM 本来就是一个流式算法。数据可以分块送来GHASH 的累加状态可以一点一点推进硬件没必要等你把所有数据都备齐才开始。AAD 和数据可以交替处理阶段机保证了状态切换的确定性。你手动操作时最常犯的错就是没有按顺序切换 GCMPH或者写完一段数据后没等 CCF 标志位就切到下一阶段导致硬件算出来的中间状态是错的。HAL 库的好处是你不需要手动切 GCMPHHAL_CRYP_Encrypt 内部已经按顺序把四个阶段全跑完了。但这也带来一个副作用如果库里某个参数设置错了你根本不知道错在哪个阶段。所以我强烈建议遇到问题时按阶段机拆解排查后面第 4 节的寄存器参考流程会专门讲这个。2. 用 HAL 库写出第一个可用的 AES-GCM 加密例程2.1 CubeMX 里怎么配置 AES 外设在 STM32CubeMX 中启用 AES我建议按下面这套参数来Algorithm 选 GCM或者 AES-GCM看固件包命名。KeySize 选 128 位如果协议要求 256 位也可以但要保证对端是一致的。DataType 选 CRYP_BYTE_SWAP这是 HAL 库里最常用、也最容易和软件参考实现对上的字节序配置。OperatingMode 在初始化时选 Encrypt解密场景后面再切。如果你打算用 LL 库要注意 STM32WL33x 的 LL 驱动对 GCM 的支持可能不完整很多底层寄存器开关没有专门封装。我的建议是GCM 这种需要状态机配合的模式优先用 HAL毕竟 HAL 库把阶段切换、标志位等待都包好了你只需要填好上下文。2.2 代码实现密钥、IV、AAD、明文全流程下面是一份可以直接抄的 HAL 加密示例。密钥、IV、AAD 我都给成了 volatile 数组方便你在调试器里实时观察。#include stm32wl33x_hal.h /* 以下测试数据来自 2023 年 NIST 风格建议向量方便和 Python 对拍 */ static const uint8_t key[16] { 0x2B, 0x7E, 0x15, 0x16, 0x28, 0xAE, 0xD2, 0xA6, 0xAB, 0xF7, 0x15, 0x88, 0x09, 0xCF, 0x4F, 0x3C }; static const uint8_t iv[12] { 0xCA, 0xFE, 0xBA, 0xBE, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07 }; static const uint8_t aad[] {H, e, a, d, e, r}; static const uint8_t plaintext[] Hello, STM32WL33x GCM!; static uint8_t ciphertext[64]; static uint8_t tag[16]; static CRYP_HandleTypeDef hcryp; int aes_gcm_encrypt_example(void) { __HAL_RCC_AES_CLK_ENABLE(); hcryp.Instance AES; hcryp.Init.DataType CRYP_BYTE_SWAP; hcryp.Init.KeySize CRYP_KEYSIZE_128B; hcryp.Init.OperatingMode CRYP_ALGOMODE_ENCRYPT; hcryp.Init.Algorithm CRYP_AES_GCM; hcryp.Init.KeyWriting CRYP_KEY_WRITE_ENABLE; HAL_CRYP_Init(hcryp); hcryp.Context.Init hcryp.Init; hcryp.Context.Header (uint8_t *)aad; hcryp.Context.HeaderSize sizeof(aad); hcryp.Context.IV (uint8_t *)iv; hcryp.Context.IVSize sizeof(iv); hcryp.Context.Key (uint8_t *)key; if (HAL_CRYP_Encrypt(hcryp, (uint8_t *)plaintext, strlen((const char *)plaintext), ciphertext, 1000) ! HAL_OK) { return -1; } memcpy(tag, hcryp.Context.Tag, 16); return 0; }几个关键点解释一下。Context.Header是 AAD 指针HeaderSize是 AAD 字节数。AAD 可以为 NULL、长度 0但是只要协议里用了 AAD这里就必须传。IVSize必须填 12也就是 96 位。STM32 的 AES 硬件只原生支持 96 位 nonceGCM 标准里其他长度的 nonce 需要额外做一次 GHASH 运算才能得到 J0硬件不帮你做。所以协议设计时老老实实定 12 字节 IV。HAL_CRYP_Encrypt的第二个参数是输入数据指针GCM 模式下输入可以是任意长度不需要凑 16 字节。加密完成后Tag 存放在hcryp.Context.Tag数组里直接 memcpy 出来即可。2.3 拿到结果后的第一件事和标准实现对拍加密跑通不等于正确。你需要在 PC 端用标准库算一份参考输出然后和芯片输出逐字节对比。我习惯用 Python 的 cryptography 库命令少结果直观。from cryptography.hazmat.primitives.ciphers.aead import AESGCM key bytes.fromhex(2b7e151628aed2a6abf7158809cf4f3c) nonce bytes.fromhex(cafebabe0001020304050607) aad bHeader plaintext bHello, STM32WL33x GCM! ct AESGCM(key).encrypt(nonce, plaintext, aad) print(ciphertext:, ct[:-16].hex()) print(tag :, ct[-16:].hex())注意AESGCM.encrypt返回的是“密文 16 字节 Tag”拼接串所以取前len(ct)-16是密文后 16 字节是 Tag。对着打印结果检查芯片输出完全一致就说明 HAL 配置正确。我实际测试中第一次跑不对的概率非常高。这时候不用慌80% 是字节序问题下面专门展开说。3. 最常见的问题为什么结果不对3.1 端序问题——这一条解决 80% 的“密文不对”STM32 的 AES 外设数据寄存器是 32 位的但算法定义中的测试向量都是按字节流给出的。硬件内部怎么把字节流排成 32 位字由 AES_CR 里的 DATATYPE 字段决定。HAL 库里常见的配置是CRYP_BYTE_SWAP它内部对应的是将每个 32 位字内的 4 个字节做反转写入寄存器。举个例子如果字节流前 4 个字节是2B 7E 15 16那么你往 DINR 寄存器写入的 32 位数值应该是0x16157E2B相当于把前四个字节调了个头。很多朋友第一次写的时候拿const uint32_t*直接强转指针或者按大端方式手动拼接结果自然和标准库对不上。解决方式有两种方案一坚持用CRYP_BYTE_SWAP然后保证所有进入 AES 外设的字节流密钥、IV、AAD、明文都按“每 4 字节一组组内字节反转”的方式写入。HAL 库内部其实帮你处理了大部分但你要确保从协议层拿到的缓冲区和 PC 端标准库用的是同一种字节解释。方案二自定义一个pack32函数不管什么 DATATYPE 都用最清晰的方式拼接然后统一设置 DATATYPE。我推荐你用一个简单的打包函数来消除歧义static uint32_t pack32(const uint8_t *buf) { return ((uint32_t)buf[0] 24) | ((uint32_t)buf[1] 16) | ((uint32_t)buf[2] 8) | ((uint32_t)buf[3]); }这个函数对应的是“寄存器高字节先收到 buf[0]”的规则。配合CRYP_BYTE_SWAP使用时如果你发现密文还是不对就换一种配置试试把DataType改成CRYP_DATA_ORDER有的库里叫CRYP_LITTLE_ENDIAN打包函数也相应替换。与其纸上谈兵猜 DATATYPE 的四种排列不如直接用一组短向量做二分定位。3.2 IV 不是 12 字节导致的坑GCM 标准支持任意长度的 IV但 STM32 硬件 AES 只支持 96 位 IV。如果你在 HAL 里把Context.IVSize填成 16、8 或其他值轻则返回错误重则硬件行为不确定。我见过有人把 16 字节的随机数直接塞进Context.IV然后 HAL 返回 HAL_OK但密文怎么都不对。打开参考手册才发现硬件在初始化阶段只读 IVR0~IVR2 这 3 个寄存器作为 nonceIVR3 必须是0x00000002也就是 J0 的最低 32 位。你给了 16 字节 IV硬件只取了前 12 字节后 4 字节被固定值为 2双方语义完全错位。正确做法是协议设计阶段就把 nonce 固定为 12 字节。如果上游协议传下来的 IV 超过 12 字节可以取前 12 字节但这样做会降低安全性最好和协议方确认。3.3 Tag 应该在哪个阶段读HAL 库帮我们省了阶段切换但很多人不知道 Tag 是从哪里读出来的。在寄存器级流程中Tag 是在最后一个阶段GCMPH11计算出来的你需要等 CCF 标志位置 1然后连续读 4 次 DOUTR凑成 16 字节。HAL 库里则简单得多HAL_CRYP_Encrypt返回后Tag 已经放在hcryp.Context.Tag里。但有一点要提醒有些早期版本的 HAL 库需要你在调用HAL_CRYP_Encrypt之前先执行HAL_CRYP_Encrypt的某种前置初始化否则Context.Tag可能是空的。我建议升级到和 STM32WL33x 配套的最新 Cube 固件包这种坑基本被修掉了。3.4 数据长度不是 16 倍数怎么办AES 对单个分组的处理永远按 128 位来但 GCM 的 CTR 模式本身是流式加密不需要像 CBC 那样做 PKCS#7 填充。最后不足 16 字节的尾块只取加密密钥流的前几个字节做异或。所以你在 HAL 里传任意长度的明文理论上都合理。但 STM32 AES 外设的块接口要求你写数据时按块写满不足 16 字节的部分要处理成“部分块阶段”。HAL 库内部已经处理了这些不需要你操心。如果你是寄存器操作就需要在最后一个块读 DOUTR 时只取需要的字节数不能把整个 16 字节都当成有效密文。3.5 解密时 Tag 怎么校验解密比加密多一个步骤你不仅要把密文恢复成明文还要校验 Tag。HAL 里解密模式通常也是用HAL_CRYP_Decrypt它内部会同样计算一遍 Tag但要注意这个函数在很多版本里是“重新计算 Tag”而不是“自动比对并返回成功/失败”。也就是说它把计算结果存在Context.Tag里你需要自己把收到的 Tag 和计算结果做常量时间比较。常量时间比较很重要不能用memcmp因为常规 memcmp 在发现第一个不同字节时就会提前返回这个时间差异足以让攻击者逐字节猜出 Tag。可以用一个简单函数static int tag_equal(const uint8_t *a, const uint8_t *b, size_t len) { uint8_t diff 0; for (size_t i 0; i len; i) { diff | a[i] ^ b[i]; } return diff 0; }3.6 常见问题速查表现象最可能原因排查方向密文和 Tag 全不对DATATYPE 字节序设置和预期不符用短向量换 DataType 配置配合 pack32 函数重试密文对Tag 不对AAD 没传或传错检查 Context.Header 和 HeaderSize 是否与协议一致密文对Tag 对但解密失败IV 两边不一致确认发送端和接收端使用相同 nonce且 nonce 未复用HAL 返回超时时钟没开启或 CCF 标志没清检查 __HAL_RCC_AES_CLK_ENABLE检查中断优先级Tag 是空的HAL 版本问题升级固件包或改用寄存器流程读 Tag解密输出乱码明文长度或分组统计错误确认解密时输入长度和加密时一致这张表覆盖了我在现场遇到过的九成问题。剩下的一成基本是时钟配置、DMA 配置或者内存对齐问题比较好处理。4. 从 HAL 到寄存器理解硬件才能真正避免坑4.1 GCM 的四个 GCMPH 状态HAL 把 GCM 流程封装成了一个黑盒黑盒能跑通当然好可一旦遇到“参考向量对不上”这种问题你手里没有能逐阶段观察的工具就只能干瞪眼。所以我建议每个做 STM32 AES 的人都至少看一遍寄存器级流程。GCMPH 字段在 AES_CR 寄存器里两个 bit 决定当前处于哪一个阶段GCMPH阶段你要做的事00初始化阶段写入 KEYR0~3 和 IVR0~2IVR3 固定写 0x0000000201AAD 阶段按 16 字节块写入 AAD直到所有 AAD 处理完10数据阶段写入明文/密文块读出密文/明文块11最终阶段等待 CCF读 DOUTR 得到 Tag每个阶段完成后硬件都会置起 CCF 标志位软件需要清掉这个标志才能进入下一阶段。有的朋友在数据阶段写一个块等一个 CCF清了标志继续写下一块这是对的但在最后一轮很多人忘了等最终阶段的 CCF直接去读 DOUTR读回来的自然不是 Tag。4.2 一个极简寄存器级参考流程下面这个函数只描述流程不处理边界细节目的就是让你看清 GCMPH 是怎么切换的。void aes_gcm_encrypt_reg(const uint8_t *key, const uint8_t *iv, const uint8_t *aad, uint32_t aad_len, const uint8_t *input, uint8_t *output, uint32_t len, uint8_t *tag) { /* 1. 关闭 AES配置 GCM 加密128 位密钥 */ AES-CR 0; AES-CR AES_CR_MODE_ENCRYPT | AES_CR_CHMOD_GCM; /* 注意具体 CHMOD 数值、DATATYPE 按参考手册填写 */ /* 2. 初始化阶段GCMPH 00 */ AES-CR ~AES_CR_GCMPH; /* 写密钥 KEYR0~3 */ AES-KEYR0 pack32(key[0]); AES-KEYR1 pack32(key[4]); AES-KEYR2 pack32(key[8]); AES-KEYR3 pack32(key[12]); /* 写 IVIVR0~2 为 nonceIVR3 固定为 2 */ AES-IVR0 pack32(iv[0]); AES-IVR1 pack32(iv[4]); AES-IVR2 pack32(iv[8]); AES-IVR3 0x00000002; AES-CR | AES_CR_EN; while ((AES-SR AES_SR_CCF) 0) {} AES-SR AES_SR_CCF; /* 3. AAD 阶段GCMPH 01 */ AES-CR (AES-CR ~AES_CR_GCMPH) | (0x1 AES_CR_GCMPH_Pos); for (uint32_t i 0; i aad_len; i 16) { uint8_t block[16] {0}; memcpy(block, aad[i], (aad_len - i) 16 ? 16 : (aad_len - i)); AES-DINR pack32(block[0]); AES-DINR pack32(block[4]); AES-DINR pack32(block[8]); AES-DINR pack32(block[12]); while ((AES-SR AES_SR_CCF) 0) {} AES-SR AES_SR_CCF; } /* 4. 数据阶段GCMPH 10 */ AES-CR (AES-CR ~AES_CR_GCMPH) | (0x2 AES_CR_GCMPH_Pos); for (uint32_t i 0; i len; i 16) { uint8_t block[16] {0}; memcpy(block, input[i], (len - i) 16 ? 16 : (len - i)); AES-DINR pack32(block[0]); AES-DINR pack32(block[4]); AES-DINR pack32(block[8]); AES-DINR pack32(block[12]); while ((AES-SR AES_SR_CCF) 0) {} /* 读取 DOUTR 得到密文块此处应连续读 4 次 */ AES-SR AES_SR_CCF; } /* 5. 最终阶段GCMPH 11读 Tag */ AES-CR (AES-CR ~AES_CR_GCMPH) | (0x3 AES_CR_GCMPH_Pos); while ((AES-SR AES_SR_CCF) 0) {} /* 连续读 4 次 DOUTR组装成 16 字节 Tag */ AES-SR AES_SR_CCF; /* 关闭 AES */ AES-CR 0; }这个代码我故意省略了很多细节比如pack32到底按什么字节序、DOUTR 读取后怎么拆成字节数组。因为你一旦开始手动操作寄存器真正要参考的是 STM32WL33x 参考手册中“AES GCM example”那一节里面给的时序图比任何人在博客里写的都准确。4.3 硬件加速下别忘了安全边界硬件 AES 外设把速度提上去了但密码学上的安全纪律不能丢。三个最容易忽略的点第一nonce 绝对不要复用。GCM 和所有 CTR 类模式一样一旦相同的密钥下出现相同的 nonce攻击者可以把两段密文异或直接恢复出明文 XOR 明文密钥形同虚设。STM32WL33x 作为无线 SoC如果它给射频数据帧做加密nonce 至少要包含一个发送端独有的帧计数器。第二Tag 必须是 16 字节不要因为协议里的“可选校验”就随便截断成 4 字节或 8 字节。截断 Tag 会显著降低伪造难度除非你有专门的带宽约束论证否则别做这个优化。第三密钥管理。STM32WL33x 可能有 OTP 或 RDP 等级保护的密钥存储不要把 AES 密钥裸写在.rodata里然后声称设备安全。密钥一旦从 Flash 泄露GCM 算法再安全也是白搭。回到最开始的问题怎么产生有效的 ciphertext 和 Tag我的答案很简单先让芯片输出和标准库对拍一致再回头审视每一个可能引入差异的环节。字节序不对就换配置AAD 不对就查上下文IV 长度不对就改协议Tag 读不出来就查阶段标志。整条链路里硬件是可靠的你的配置才是最容易出问题的地方。按照这个方法走一遍你很快就能让 STM32WL33x 流出和云端服务器完全一致的密文和 Tag。
返回列表