ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32离线支付终端安全方案:多层加密与HMAC-SHA256实战

STM32离线支付终端安全方案:多层加密与HMAC-SHA256实战

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 中间值,虽然只是中间值,但结合其他信息有可能被利用。从那以后我对日志内容就格外小心。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进