1. 项目缘起与整体设计思路拆解
离线支付终端这个方向,我在几个嵌入式项目里都碰过,最早接触是给一台自助售货设备做支付模块升级。当时的需求很直接:设备部署在没有稳定网络的场景里,用户扫码或者刷卡之后,终端必须自己完成交易合法性判断,不能依赖后台实时回连。这就引出了一个很现实的问题——没有网络,谁来保证这笔交易没被篡改、没被重放、没被伪造?STM32 这类 MCU 资源有限,跑不动完整的 TLS 栈,也不适合做重量级的非对称运算,所以整套安全方案必须围绕“轻量、可离线、可验证”来设计。
这个项目的核心,说白了就是在 STM32 上搭一套能自洽的支付终端安全体系。它要解决三件事:第一,交易数据在终端本地生成后,怎么保证从生成到落盘这一段不被动手脚;第二,终端和后台之间虽然离线,但数据最终要同步,同步时怎么让后台相信这份数据确实来自合法终端、且内容完整;第三,密钥和敏感数据存在 Flash 里,怎么防止被直接读出来。围绕这三点,方案选型上我最终落到了“多层加密 + HMAC-SHA256 完整性验证”这条线上。
为什么是 HMAC-SHA256 而不是直接上 RSA 签名?这里得说清楚。STM32F1/F4 这类主流型号,做一次 2048 位 RSA 签名验签,纯软件实现动辄几百毫秒甚至上秒级,而且代码体积和 RAM 占用都不小。HMAC-SHA256 是纯对称运算,一颗 72MHz 的 M3 内核跑一次 SHA256 大概几毫秒到十几毫秒,配合硬件 CRC 和 DMA 还能再压。对于离线支付这种“交易频率不高但要求确定性响应”的场景,HMAC 的性价比明显更高。当然,HMAC 的前提是双方共享密钥,所以密钥的分发和保护就成了整个方案的安全底座,这一点后面会重点展开。
多层加密的思路也是被资源逼出来的。单层 AES 加密能解决机密性,但解决不了完整性;单层 HMAC 能解决完整性,但交易金额、卡号这类敏感字段还是明文。所以我把方案拆成三层:第一层是传输层的帧加密,用 AES-128-CBC 对整帧业务数据做加密,保证抓包看不到明文;第二层是完整性层,用 HMAC-SHA256 对“密文 + 关键头部字段”做认证,防止密文被篡改或帧被拼接;第三层是存储层,对落盘的密钥和交易流水做单独的加密和校验,防止设备被拆后直接读 Flash。三层各管一件事,互不越界,这样每一层的实现都足够简单,出问题也容易定位。
这里有个设计上的取舍值得说。很多人喜欢把加密和认证揉在一起,比如用 AES-GCM 一次搞定。GCM 确实优雅,但在 STM32 上纯软件实现 GCM 的 GHASH 部分对 M3 来说并不轻松,而且一旦 nonce 管理出问题,安全性会断崖式下跌。我选择 CBC + HMAC 的组合,虽然多了一次运算,但每个环节的行为都非常可预测,调试和审计时心里有底。对于支付类设备,可预测性有时候比理论上的最优更重要。
适用人群方面,这个方案适合有一定 STM32 开发基础、正在做离线设备安全通信的工程师,也适合想了解嵌入式轻量安全体系怎么落地的人。如果你只是想让串口数据别裸奔,那用个简单的异或校验就够了,没必要上这套;但如果你面对的是涉及金额、身份、凭证的场景,那这套分层思路值得参考。
2. 核心安全机制的原理与关键细节
2.1 HMAC-SHA256 在 MCU 上到底怎么跑
HMAC 的公式看起来简单:HMAC(K, m) = H((K' ⊕ opad) || H((K' ⊕ ipad) || m))。但真要在 STM32 上落地,有几个细节必须抠清楚。首先是密钥长度,SHA256 的块大小是 64 字节,如果密钥超过 64 字节要先哈希一次,不足 64 字节要右补零。我一般直接把共享密钥定成 32 字节,既满足强度要求,又省去补零的边界处理,代码里少一个分支就少一个出错点。
其次是运算流程。一次 HMAC 需要跑两轮 SHA256:内层先算 ipad 拼接消息的哈希,外层再算 opad 拼接内层结果的哈希。这意味着消息要过两遍哈希引擎。如果消息比较长,比如一帧几百字节的交易数据,纯软件实现时要注意把中间状态保存好,别每次都从头初始化。我用的做法是封装一个hmac_sha256_ctx结构体,把内外两轮的上下文都放进去,init时把 ipad/opad 预处理结果算好缓存起来,后续每次认证只需要喂消息和取结果,省掉重复的密钥扩展开销。
还有一个容易被忽略的点:比较 HMAC 结果时绝对不能用memcmp。memcmp遇到第一个不同字节就返回,攻击者可以通过测量比较耗时来逐字节猜出正确值,这就是典型的时序侧信道。正确做法是自己写一个常数时间比较函数,把整个 32 字节全部异或累加,最后判断累加结果是否为零。这个细节在很多开源实现里都做错了,但在支付场景里它是必须的。
static int ct_memcmp(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 ? 0 : -1; }这段代码看着朴素,但它保证了无论哪一位不同,执行时间都一样。实测下来,在 72MHz 的 M3 上比较 32 字节大概几十个时钟周期,完全可以接受。
2.2 多层加密的分层边界怎么划
分层最怕的就是边界模糊,导致某一层该管的事没管住。我在这个项目里把边界定得很死:AES-CBC 只负责“让明文不可读”,它不负责判断数据有没有被改;HMAC 只负责“判断数据有没有被改”,它不负责隐藏内容;存储加密只负责“让 Flash 里的密钥不可直接读”,它不参与通信链路。三层之间通过明确的输入输出契约连接,任何一层出问题都不会污染另外两层的逻辑。
具体到帧结构上,一帧完整的交易数据长这样:头部包含协议版本、终端编号、交易序号、时间戳,中间是 AES 加密后的业务载荷,尾部是 32 字节 HMAC。HMAC 的计算范围是“头部关键字段 + 密文载荷”,注意头部里哪些字段参与认证要提前约定好,比如交易序号和时间戳必须参与,否则攻击者可以改序号做重放。协议版本这种不敏感的字段可以不参与,减少计算量。
这里有个实操中的坑:CBC 模式的 IV 怎么处理。IV 必须随机且不可预测,但 MCU 上真随机源往往不理想。我的做法是用硬件 RNG 如果芯片支持,不支持的话就用“上次 HMAC 结果的后 16 字节”作为下一次的 IV 种子,再混入一个单调递增的计数器。这样既保证了 IV 不重复,又不需要额外的随机数发生器。IV 本身不需要保密,直接放在帧头明文传输即可,但一定要参与 HMAC 计算,防止被替换。
2.3 密钥体系与存储保护
整个方案的安全性最终压在密钥上,所以密钥怎么存、怎么用、怎么换,必须想清楚。我采用的是两级密钥结构:主密钥(Master Key)烧录在芯片的受保护区域,用于派生工作密钥;工作密钥(Session Key)由主密钥加上终端编号、日期等信息通过 HMAC 派生出来,每次开机重新派生,不落盘。这样即使有人把 Flash 整个读出来,拿到的也只是派生后的中间值,没有主密钥就无法还原工作密钥。
主密钥的保护依赖芯片本身的读保护机制。以常见的 STM32F4 为例,开启 RDP Level 1 之后,调试接口无法读取 Flash 内容,只能整片擦除;如果进一步上到 Level 2,连擦除都不允许,芯片基本变成一次性。对于支付终端,我建议至少 Level 1,配合外部 tamper 检测引脚,一旦外壳被打开就触发密钥清零。这个清零逻辑要写在最早的启动阶段,别等系统跑起来再判断,否则窗口期太长。
存储层的加密我用的是 AES-128-ECB 对密钥块单独加密,这里用 ECB 不是偷懒,而是因为密钥块本身是定长随机数据,不存在模式泄露的问题,ECB 反而实现最简单、最不容易出错。交易流水落盘时则用 CBC 加 HMAC,和通信层保持一致的思路。这里要提醒一句:存储加密的密钥不能和通信密钥共用,必须独立派生,否则一个环节泄露会连带另一个环节。
3. 实操过程与核心环节实现
3.1 开发环境与基础依赖搭建
我用的是一套比较常规的组合:STM32CubeMX 做引脚和时钟初始化,Keil MDK 或者 STM32CubeIDE 做编译调试,加密算法部分没有直接引第三方大库,而是自己裁剪了一份轻量实现。原因很简单,很多通用加密库为了兼容各种平台,代码里带大量条件编译和抽象层,放到 MCU 上又大又难审计。自己裁一份只保留 SHA256、HMAC、AES-128 的实现,代码量能压到几 KB,而且每一行都能看明白。
SHA256 的实现我参考了公开的轻量级实现思路,核心是一个 64 字节的块处理函数加一个状态结构体。AES 部分用查表法实现,S 盒和逆 S 盒各占 256 字节,加上轮密钥扩展,整体 RAM 占用可控。如果你的芯片有硬件加密外设(比如某些型号带 AES 加速器),那优先用硬件,软件实现只作为兜底。硬件 AES 在 CBC 模式下配合 DMA,加密一帧几百字节的数据几乎不占 CPU 时间。
工程目录我习惯这样组织:/crypto放算法实现,/security放密钥管理和帧封装,/app放业务逻辑,/bsp放 Flash、RNG、UART 驱动。这样分层之后,安全相关的代码和业务代码物理隔离,做代码审计时范围很清晰。编译选项上,加密相关文件开-O2但不开-O3,因为过度优化有时会让常数时间比较函数被编译器改写成提前返回,这个坑我踩过一次,后来在比较函数里加了volatile才稳住。
3.2 交易帧的封装与认证流程
一帧交易的完整生命周期是这样的:业务层生成交易结构体,序列化成字节流;安全层先算 HMAC 的头部字段,再对载荷做 AES-CBC 加密,然后把 IV、密文、HMAC 拼成完整帧;最后通过 UART 或者 SPI 发给通信模块。接收方向反过来:先校验帧长度和头部合法性,再用 HMAC 验证完整性,验证通过才解密载荷,解密后再做业务层校验。
这里的关键是“先验后解”的顺序不能反。很多人习惯先解密再校验,觉得这样代码顺,但这是错的。解密操作本身会消耗资源,如果攻击者发大量伪造帧,先解密就等于给了对方一个免费的资源消耗通道。先验 HMAC,不通过直接丢弃,成本只有一次哈希运算,抗滥用能力强得多。
HMAC 的计算范围我定成“帧头前 12 字节 + IV + 密文”。帧头前 12 字节包含版本、终端号、交易序号,这三个字段任何一个被改都会导致 HMAC 失败。时间戳我放在帧头后段,也纳入计算范围,但允许一定的时间窗口容差,比如前后 5 分钟,超出窗口的帧即使 HMAC 正确也拒绝,这是防重放的第一道闸。第二道闸是交易序号,终端本地维护一个单调递增计数器,收到的序号必须大于已处理的最大序号,否则丢弃。两道闸配合,重放攻击基本没有空间。
int frame_verify(const uint8_t *frame, size_t len, frame_t *out) { if (len < FRAME_MIN_LEN) return -1; const uint8_t *hmac_recv = frame + len - 32; uint8_t hmac_calc[32]; hmac_sha256(work_key, 32, frame, len - 32, hmac_calc); if (ct_memcmp(hmac_recv, hmac_calc, 32) != 0) return -2; if (check_replay(frame) != 0) return -3; return aes_cbc_decrypt(frame, len, out); }这段代码是整个安全层的缩影,顺序、边界、返回值都很明确。实际调试时我建议在每个失败分支加一个独立的错误码,方便现场排查到底是完整性失败还是重放失败,别都返回一个 -1,到时候日志里看不出区别。
3.3 密钥派生与开机自检
开机流程里有一段专门做安全自检,顺序不能乱。第一步检查 RDP 等级,如果发现被降级,直接进入锁定状态;第二步从受保护区域读取主密钥,做一次完整性校验(主密钥存储时附带一个 HMAC 标签);第三步用主密钥派生工作密钥,派生输入包括终端唯一 ID 和当前日期;第四步做一次自测试,用固定向量跑一遍 SHA256 和 AES,确认算法实现没被篡改。
密钥派生我用的是 HMAC 做 KDF:work_key = HMAC(master_key, terminal_id || date || purpose)。purpose字段用来区分不同用途的密钥,比如通信密钥、存储密钥、日志密钥各派生一份,互不干扰。这个设计的好处是主密钥只有一个,但实际使用的密钥有很多个,任何一个工作密钥泄露都不会直接暴露主密钥,而且换日期就自动轮换,降低了长期使用同一密钥的风险。
自测试的固定向量要提前算好烧进代码,比如 SHA256("abc") 的标准结果。每次开机跑一遍,结果对不上就拒绝进入支付流程。这个检查成本很低,几毫秒的事,但能挡住一类很隐蔽的攻击——有人通过故障注入或者篡改 Flash 让算法实现产生错误结果,自测试能第一时间发现异常。
4. 常见问题与排查技巧实录
4.1 典型问题速查表
实际调试中遇到的问题五花八门,我整理了一张速查表,覆盖了大部分高频故障。
| 现象 | 可能原因 | 排查方向 | 处理建议 |
|---|---|---|---|
| HMAC 校验始终失败 | 密钥不一致或字节序问题 | 打印双方密钥哈希对比 | 确认密钥派生输入完全一致 |
| 偶发 HMAC 失败 | 帧长度计算错误或缓冲区越界 | 检查 len 参数和尾部偏移 | 用固定帧做单元测试 |
| 解密后数据乱码 | IV 不匹配或 CBC 填充错误 | 核对 IV 传递和 PKCS7 填充 | IV 必须参与 HMAC 且明文传输 |
| 重放检测误判 | 计数器掉电丢失或回绕 | 检查计数器存储位置 | 计数器落盘并做掉电保护 |
| 开机自检不过 | 算法实现被优化改写 | 对比编译前后汇编 | 关键函数加 volatile 或关优化 |
| 通信偶发丢帧 | UART 波特率偏差或 DMA 冲突 | 示波器看波形和 DMA 状态 | 降低波特率或改用中断收 |
这张表里我特别想说的是“偶发 HMAC 失败”这一条。有次现场反馈说设备十次里有一两次认证不过,重启就好。查了半天发现是接收缓冲区在 DMA 和主循环之间共享,偶尔主循环还没处理完 DMA 又写入了新数据,导致帧被截断。后来改成双缓冲加信号量,问题消失。这类问题在单线程裸机程序里很常见,本质是并发访问没保护好,和安全算法本身无关,但表现得很像安全问题,容易带偏排查方向。
4.2 几个踩过的坑和独家心得
第一个坑是 Flash 写保护的范围。我一开始只保护了主密钥所在的扇区,结果发现工作密钥派生用的终端 ID 存在另一个扇区,那个扇区没保护,被人改了终端 ID 就能派生出不同的工作密钥。后来把终端 ID 也挪进受保护区域,并且参与主密钥完整性校验,才算堵上。教训是:安全相关的所有输入都要纳入保护范围,不能只盯着最核心的那个。
第二个坑是时间戳的容差设置。容差设太小,设备时钟漂移会导致合法帧被拒;设太大,重放窗口就宽。我最后定的是前后 5 分钟,同时要求终端每次开机通过某种方式校准一次时间(比如从通信模块获取),把漂移控制在秒级。如果你的设备完全没有时间源,那时间戳这道闸就得弱化,更多依赖单调计数器。
第三个心得是关于调试接口的。开发阶段为了方便,RDP 是关着的,结果有次样机流出去了,被人直接读出了 Flash 里的测试密钥。虽然测试密钥不是生产密钥,但这事提醒我:从第一版硬件开始就应该把 RDP 流程走通,别等到量产前才想起来。现在我的做法是开发板留一个跳线帽控制 RDP 等级,量产板直接熔断,物理上杜绝后路。
还有一个关于性能的实测数据分享。在 72MHz 的 M3 上,纯软件 SHA256 处理 256 字节数据大约 8 毫秒,HMAC 因为要跑两轮,大约 15 毫秒;AES-128-CBC 加密 256 字节大约 6 毫秒。一帧完整的“加密 + 认证”大约 20 毫秒出头。对于支付终端来说这个延迟完全可接受,但如果你的场景要求更高吞吐,那就得上硬件加密外设,或者把 SHA256 的块处理用汇编优化。我试过用查表加循环展开把 SHA256 压到 5 毫秒以内,代价是代码体积涨了一倍多,值不值得看你的 Flash 余量。
最后说一个容易被忽视的点:日志。安全相关的失败事件一定要记日志,但日志本身不能泄露敏感信息。我的做法是只记录错误码、时间戳和帧序号,绝不记录密钥、明文或者完整密文。日志存储也要加密,用独立的日志密钥,和通信密钥分开。这样即使日志被读走,也拼不出有用的信息。这个习惯是从一次安全审计里学来的,当时审计方指出我们的调试日志里打印了 HMAC 中间值,虽然只是中间值,但结合其他信息有可能被利用。从那以后我对日志内容就格外小心。