做嵌入式这几年,我见过太多“看起来加了密,实际一捅就破”的产品。最常见的一种:把密钥、校准数据、设备证书直接放在Flash里,打开读保护就当安全了,结果攻击者用几条命令就能让固件自己把数据吐出来,或者干脆降级固件版本绕过限制。真正在MCU上做Secure Flash Storage,核心不是“加密”,而是回答清楚一个问题:当攻击者物理拿到你的设备、能改你的固件、甚至能拆芯片做侧信道分析时,你的密钥和数据还拿不拿得出来?
这篇内容围绕“PSA Certified MCU提供Secure Flash Storage方案”展开,讲的是如何利用通过PSA Certified认证的MCU自带的硬件隔离能力,在资源受限的嵌入式设备上搭建一套真正可信的安全闪存存储。适合正在做物联网设备安全设计、产品接入方案评估、或者刚接手MCU安全功能开发的工程师。我会把架构原理、数据格式、密码学选型、实操步骤和踩坑记录一次讲透,不写PPT式的废话。
1. 为什么MCU上的安全存储不是"给Flash加个锁"这么简单
1.1 三个真实存在的攻击角度
先泼一盆冷水。在MCU上保存敏感数据,如果只依赖“读保护”和“加密存储”,至少有三个方向的攻击能轻松撕开口子。
第一个是物理攻击。芯片开封后,用聚焦离子束(FIB)修线、用微探针采信号,甚至用化学腐蚀逐层剥离,都能直接读取Flash内容。这类攻击成本高,但针对高价值设备(比如支付终端、门禁卡母卡)完全可行。对抗它的不是加密算法,而是密钥永不落盘、密钥与芯片物理绑定的设计。
第二个是固件重打包攻击。攻击者不用读Flash,而是拿到你的固件镜像,反汇编找到读取密钥的逻辑,修改其中的判断条件或数据地址,重新签名(如果签名校验有漏洞)或者直接利用非安全侧漏洞注入代码,让设备自己把密钥输出给攻击者。这个过程中,加密算法一次都没被破,但数据照样丢了。
第三个是调试接口后门。芯片出厂时默认开着的调试口、工厂测试模式下残留的扫描链、Bootloader降级后的DFU接口,都是常见突破口。攻击者只要能把CPU带入调试状态,就能读任意Flash区域。很多芯片的读保护在“降级固件 → 进入Bootloader → 关闭保护”的组合拳下根本撑不住。
所以,安全存储的真实需求是:即使攻击者拿到芯片、可以选择任意物理或逻辑手段,存储在设备上的机密信息仍然无法被以明文或可用形式提取。这已经不是“加个AES库”能解决的问题,而是需要一个从硬件隔离到系统软件的完整信任链。
1.2 PSA Certified 到底认证了什么
PSA(Platform Security Architecture)是Arm提出的物联网安全架构,包含10个安全目标(Security Goals),覆盖安全启动、安全更新、安全存储、隔离、密码学、初始状态安全等。其中和Secure Flash Storage直接相关的是Secure Storage、Isolation、Secure Boot和Secure Update这四项。
PSA Certified是Arm联合多家独立测试实验室做的认证体系,分为三个等级:
| 等级 | 评估内容 | 适合场景 |
|---|---|---|
| Level 1 | 安全文档、威胁模型与方案自评估 | 早期评估、产品规划 |
| Level 2 | 实验室评估,验证隔离机制、TrustZone配置等安全功能的实际有效性 | 绝大多数物联网产品 |
| Level 3 | 在Level 2基础上增加侧信道、物理攻击防护测试 | 支付、身份认证、高价值资产 |
选MCU的时候,很多团队容易陷入“Level越高越安全”的误区。实际上Level 3会显著增加成本和功耗,对智能锁、传感器节点这类设备反而不合适。我的经验是:先明确产品的威胁模型,再对应选择认证等级。比如一个只存校准参数和临时会话密钥的设备,Level 2 + 良好的密钥管理已经足够;但如果要做支付终端或数字版权管理,就该上Level 3设备。这个决策直接影响后续存储方案设计的复杂程度。
2. 硬件隔离:让"钥匙"和"锁具"分家
2.1 TrustZone-M 与安全/非安全世界划分
PSA Certified MCU普遍采用Arm TrustZone技术(Cortex-M23/M33/M55/M85内核),它把处理器和内存分为两个世界:安全世界(Secure World)和非安全世界(Non-Secure World)。非安全世界跑RTOS、协议栈、应用逻辑,安全世界运行安全服务(如密钥管理、安全存储、安全启动)。
这个划分的关键在于:隔离是在CPU总线层级强制执行的。非安全代码访问安全内存区域,直接触发Bus Error或HardFault,不存在“软件绕过”的可能。也就是说,哪怕攻击者完全控制了非安全侧的应用代码,也只是一只“在笼子里乱撞的鸟”,根本碰不到安全世界里的数据。
我在实际项目里把这种设计理解成“钥匙和锁具分家”:钥匙(密钥、证书、关键数据)存放在安全世界的保险柜里,锁具(加密/解密逻辑)也运行在安全世界,非安全世界只有一个“寄柜服务窗口”,通过固定接口申请存取。应用层甚至不知道密钥长什么样,只知道“存了个数据”“取回来一段密文”。
2.2 安全审计单元(SAU)与安全MPU的配合
TrustZone在Cortex-M上的落地依赖两个硬件单元:SAU(Security Attribution Unit)和IDAU(Implementation Defined Attribution Unit)。
SAU由软件配置,为每个内存区域标注安全属性:Secure、Non-Secure或Non-Secure Callable(允许非安全侧调用安全函数的特殊入口)。IDAU则由芯片厂商固化为硬件属性,无法被软件修改。两者的规则组合后,形成一个“只有更严格者生效”的最终属性表。这意味着即使SAU配置出错,IDAU的硬件下限也能兜底,保证了隔离机制不会因软件bug被整体击穿。
实际操作中,芯片上电后安全启动代码做的第一件事就是配置SAU,把安全Flash和安全SRAM划出来。比如一块512KB Flash的MCU,我会把前面64KB作为安全区域,存放安全固件和存储对象区;剩余448KB作为非安全区域,跑应用。这个比例不是拍脑袋定的,需要根据安全服务的代码量、存储对象大小的上限,以及非安全侧应用对资源的需求综合评估。
这里还要提一个和MCU启动流程强相关的点:SAU必须在任何非安全代码执行前完成配置。所以安全启动(Secure Boot)流程的第一个阶段必须在安全世界执行,先加载安全固件、配置SAU/MPU,再跳转到非安全侧启动RTOS。如果顺序反了,非安全代码可以先执行并篡改SAU配置,整个隔离就形同虚设。
2.3 安全世界和非安全世界的通信:NSPE与SPE
有了隔离,还要让两个世界能协作。PSA定义了两种处理环境:SPE(Secure Processing Environment)和NSPE(Non-Secure Processing Environment),两者通过NSPM(Non-Secure Processing Environment Management)接口通信。
在Cortex-M上,NSPE调用SPE服务的方式是触发一个特定指令(如SG指令)进入安全世界,再由安全侧的SVC Handler分发请求。用户态应用不需要关心底层细节,只需要调用TF-M(Trusted Firmware-M)提供的PSA API。比如:
// 非安全侧APP调用安全存储接口 psa_status_t status = psa_its_set(uid, data_length, p_data, create_flags); // uid: 数据对象标识符 // data_length: 数据长度 // p_data: 数据指针 // create_flags: 创建标记,如PSA_STORAGE_FLAG_WRITE_ONCE// 读取数据对象 psa_status_t status = psa_its_get(uid, offset, size, p_data, &data_length);这类API调用看起来像一个普通库函数,但内部经历了“非安全调用入口 → 参数校验 → 安全世界执行 → 结果返回”的完整路径,并且参数在穿越安全边界时会被重新校验。这里要特别提一个坑:别在非安全侧就解引用参数指针。正确做法是传数据缓冲区地址和长度,由安全侧去拷贝和校验,防止TOCTOU(Time-of-Check to Time-of-Use)攻击。
TF-M还区分了两套接口:psa_its_*(Internal Trusted Storage,内部可信存储)和psa_ps_*(Protected Storage,受保护存储)。ITS侧重于完整性验证,适合存放本身不需要机密但必须防篡改的数据;PS则在ITS基础上增加了机密性保护(加密)。设计存储方案时,要区分对待:设备公钥、校准参数可以放进ITS,私钥、会话密钥、证书私钥则必须放进PS。
3. 安全闪存存储的数据格式与生命周期设计
3.1 数据对象格式:不是简单"加密后写进去"
把密文直接写到Flash是不够的,因为攻击者可以执行“密文剪切粘贴攻击”或“旧数据重放攻击”。一个完整的安全存储对象,至少要包含几个部分:
| 字段 | 作用 | 说明 |
|---|---|---|
| Magic | 标识对象起始 | 固定值,用于启动时扫描对象列表 |
| 版本号 | 标识对象结构版本 | 结构升级时用于数据迁移 |
| 数据大小 | 明文原始长度 | 解密后校验完整性 |
| Flags | 属性标记 | 如只写一次、可覆盖等 |
| Rollback Counter | 抗回滚计数器 | 防止旧版本数据重放 |
| Nonce | 随机数/计数器 | 用于认证加密算法,不能重复 |
| Ciphertext | 密文数据 | AES-GCM/CCM加密输出 |
| MAC/Tag | 完整性标签 | 由认证加密算法产生 |
这个结构不是拍脑袋定的。其中Magic的作用是扫描:安全存储在Flash里通常不是单块数据,而是一个对象池。启动时遍历Flash块,用Magic判断是否是有效对象。版本号和Rollback Counter是配套的:版本号变了,数据结构可能不兼容;回滚计数器递增,可以让攻击者把固件和数据恢复到一个调用过的旧版本时,系统直接拒绝。
一个容易忽略的细节是数据对象ID(UID)的分配规则。TF-M用UID来索引对象,UID是调用者和存储系统之间约定的“文件名”。如果UID可以被攻击者预测或篡改,可能导致对象替换攻击(用攻击者自己的对象覆盖合法对象)。所以UID应该在产品设计阶段就统一规划,并且非安全侧的UID使用范围要避开安全侧保留段。
3.2 掉电安全与磨损均衡
Flash的物理特性决定了“写入”和“擦除”是不同粒度的操作,而且写入过程中掉电会产生半写状态。安全存储在嵌入式设备上常会遇到供电不稳或电池被拔的情况,因此掉电一致性必须提前设计。
一个常用的方案是双槽交替写入,也叫Ping-Pong或Double Buffer:
- 对象更新时,不直接覆盖旧数据,而是写入另一个空槽;
- 写入完成后更新状态标记(比如写一个“New”标记到头部);
- 下次上电启动时,根据状态标记决定选用哪个副本;
- 旧的槽在下一次更新时被复用。
这个方案的代价是Flash空间利用率低,但换来了操作的原子性。另一种方案是在每个块头部记录序列号,启动时找序列号最高的块作为最新版本。TF-M的SST(Secure Storage)模块实际就用了类似机制,叠加了磨损均衡逻辑,避免每次都擦写同一个块导致Flash寿命瓶颈。
还要补充一个经验:Flash磨损均衡不能只在存储层做。安全存储频繁写入的Rollback Counter、日志型数据,会集中消耗特定扇区擦写寿命。我见过一块内嵌Flash标称10万次擦写寿命,因为某个对象被频繁更新,整块Flash在几个月内就报废了。解决办法有二:一是把频繁更新的小数据放到RAM镜像,定期批量写回Flash;二是用NOR Flash的多个扇区轮流做日志记录区域,让磨损均匀分布。
3.3 生命周期:从出厂预置到设备退役
安全存储不是部署完就一劳永逸的,它的生命周期贯穿设备的整个服役过程。
出厂阶段:芯片出厂时,安全存储区域是空白的。此时要做三件事:一是烧录硬件唯一密钥HUK(如果有OTP/eFuse则提前烧好),二是用固件签名密钥对Bootloader和初始安全固件签名,三是将设备的身份证书、生产批次信息等一次性数据写入安全存储区域,并标记为PSA_STORAGE_FLAG_WRITE_ONCE,不可再改写。这一步如果漏了,后续想再写入只读对象就要重新回产线,成本很高。
运行阶段:设备在用户手里运行时,安全存储对象的生命周期由业务逻辑决定。比如安全固件升级时,需要保留用户的配网凭证;恢复出厂设置时,要清除用户敏感数据但保留信任根证书。这要求API设计上区分“可删除”“不可删除”“只读”等不同标记。
退役阶段:设备报废或转售时,安全存储内的数据是否可被可靠清除?PSA对这种场景强调“安全擦除”:不仅要把数据区覆盖写,还要考虑密钥在OTP里无法删除的问题。实际产品中,退役设备通常的做法是把Flash中的密钥索引作废,配合后端服务器注销设备证书来实现“逻辑删除”。
4. 密码学实现细节:哪些参数要仔细设计
4.1 密钥从哪来:HUK与设备唯一密钥
安全存储的信任根是HUK(Hardware Unique Key),存储在芯片内部的OTP、eFuse或专用安全单元中,软件无法直接读取明文。每次设备上电,安全固件通过平台接口获取HUK,然后派生各种用途的子密钥。
注意这里的关键设计:不要直接用HUK加密业务数据。原因是HUK只有一份,如果业务密钥泄漏或需要轮换,HUK无法更换。正确做法是用HKDF(基于HMAC的密钥派生函数)从HUK派生出多个用途密钥:
// 伪代码:从HUK派生存储加密密钥 hkdf_sha256(huk, huk_len, salt, "secure_storage_key", 18, derived_key, key_len); // salt可以是设备序列号或随机数,保证不同设备即使HUK相同(一般不会)也得到不同派生密钥派生标签(info)要区分用途,比如存储加密密钥、固件签名验证公钥的散列、会话密钥的种子,各用各的标签。这样即使某个业务密钥泄漏,攻击者也很难推导出其他密钥。这个设计模式在PSA的文档里叫“Per-Device Key Hierarchy”。
4.2 认证加密算法与参数选择
Secure Flash Storage的机密性和完整性需求,对应的是认证加密(AEAD)算法,最常用的是AES-GCM和AES-CCM。这两种算法在加密的同时产生MAC,能同时检测密文和附加认证数据(AAD)是否被篡改。
为什么不用AES-CBC/AES-ECB?因为它们是纯机密性算法。攻击者虽然看不懂密文,但可以翻转密文块,导致解密后的明文发生可预测的变化。在安全存储场景里,数据的冗余信息(比如数据头格式)很容易被攻击者利用来构造有效(或者说“不是随机乱码”)的篡改数据,所以完整性校验和机密性同等重要。
参数选择上有几个取舍点要重点说明:
- Nonce长度:GCM对Nonce非常敏感,如果实际加密时重复使用相同Nonce,攻击者能直接恢复出密钥流。推荐使用96位(12字节)Nonce,其中一部分用随机数,一部分用计数器,确保统计上不会重复。
- Tag长度:GCM默认支持128位Tag,但有些协议栈允许缩短为64位甚至32位。物联网场景里我建议至少用112位以上。少8个字节的存储空间省不了多少Flash,但安全性差距是数量级的。
- AAD(附加认证数据):把对象UID、版本号、存储位置等元数据放入AAD,可以让篡改行为在解密前就被识别。如果不放AAD,攻击者可能把A对象的内容整体搬到B对象的存储位置,系统读B时发现数据“合法”但内容其实来自A。
4.3 抗回滚:不只是检测篡改
安全存储要防的不仅仅是“数据被改”,还有“数据被换成旧版本”。比如攻击者把设备的固件降级到有漏洞的旧版本,然后从备份Flash里恢复旧存储数据,让系统认为一切都正常——这种回滚攻击在现实中非常常见,很多“无法修复”的设备漏洞就是这么被利用的。
对抗回滚的核心是单调递增的Rollback Counter。每次固件升级或关键数据更新时,计数器递增并安全存储。计数器本身也需要防篡改:要么存入OTP区域(写一次就不能改),要么用Flash存储并配合签名/认证码校验,并在启动时与固件版本联动校验。
一个实用的联动方案是:Bootloader启动时读取当前固件版本号和安全存储里的Replay Counter,两者比对。若计数器小于固件中的最低要求版本,则拒绝启动并进入恢复模式。这样即使攻击者恢复了旧存储数据,也会因为计数器不匹配被拒绝,整个信任链是闭合的。
5. 实操:在PSA Certified MCU上落地Secure Storage
5.1 选择开发平台与参考软件
目前最容易上手的PSA Certified MCU开发平台,可以参考NXP LPC55S6x系列或STM32L5系列,两者都内置Cortex-M33内核并支持TrustZone。软件上使用开源参考实现TF-M(Trusted Firmware-M),它实现了PSA的安全启动、安全存储、密码服务分区,并且支持通过多样化平台接口适配不同MCU。
搭建开发环境时,我的建议是直接用Arm官方的开源工具链:
- 编译工具链:arm-none-eabi-gcc或Arm Compiler 6
- 调试下载:OpenOCD + CMSIS-DAP探针,或使用芯片厂商自己的IDE(不依赖IDE也能编,但厂商工程整理好的配置文件能省不少事)
- 参考文档:TF-M官方文档和PSA API头文件是首选,比网上各种二手教程准确得多。例如
psa/storage_common.h、psa/its.h中定义了完整API。
另外提一句,有读者问到Cadence OrCAD如何快速导出MCU引脚信息,这和安全开发虽无直接关系,但我在设计安全产品原理图时发现:把SWD调试口、安全启动模式选择引脚、OTP烧录引脚这些关键信号提前规划好,比后期飞线改版重要得多。建议在原理图阶段就用OrCAD的Part Manager把MCU的电源域、调试域单独标注,方便后续跟固件团队对齐引脚约束。
5.2 配置安全分区与构建SPE/NSPE
TF-M的工程通常一次性生成两个镜像:SPE镜像(安全侧固件)和NSPE镜像(非安全侧应用)。构建的关键步骤是修改平台配置文件,常见配置项包括:
| 配置项 | 说明 | 参考值 |
|---|---|---|
| FLASH_AREA_SIZE | 安全分区Flash大小 | 典型64KB~128KB |
| PS_ENABLED | 启用Protected Storage | 1 |
| ITS_ENABLED | 启用Internal Trusted Storage | 1 |
| ITS_MAX_ASSET_SIZE | 单个对象最大大小 | 取决于业务数据,如2KB |
| ITS_NUM_ASSETS | 支持的最大对象数 | 如30~50个 |
| CRYPTO_AEAD_ALG | 加密算法 | AES-CCM或AES-GCM |
配置完成后,编译脚本会把安全和非安全两份镜像链接到指定地址。这里最容易出的问题是Flash区域重叠:如果安全Flash的基地址和大小配置与链接脚本不一致,启动时TrustZone属性表会指错位置,现象是安全固件能跑但非安全应用一访问就HardFault。排查时优先检查链接脚本里FLASH_ORIGIN与SAU配置是否匹配。
我建议的做法是把内存布局表写进工程README,像这样:
- 0x08000000: Bootloader(非安全侧可读,写保护)
- 0x08010000: SPE镜像(Secure Flash)
- 0x08030000: NSPE镜像(Non-Secure Flash)
- 0x08070000: SST存储区(Secure Flash,对象池)
这样大家读代码、调配置时心里都有谱,不会你改一块我改一块。
5.3 在非安全应用中调用安全存储API
构建好SPE/NSPE镜像后,写一个简单的存储接口调用,验证整条链路是否通:
#include "psa/its.h" #define UID_DEVICE_CERT 0x1001 static const uint8_t cert_data[] = "device_cert_01"; void secure_storage_demo(void) { psa_status_t status; // 写入一个设备证书对象,标记为只写一次 status = psa_its_set(UID_DEVICE_CERT, sizeof(cert_data), cert_data, PSA_STORAGE_FLAG_WRITE_ONCE); if (status != PSA_SUCCESS) { // 处理错误 return; } // 读取并验证 uint8_t read_buf[sizeof(cert_data)]; size_t read_len = 0; status = psa_its_get(UID_DEVICE_CERT, 0, sizeof(cert_data), read_buf, &read_len); if (status == PSA_SUCCESS && read_len == sizeof(cert_data)) { // 数据读取成功 } }这段代码看起来平平无奇,但它内部的调用路径是:非安全侧通过SG指令进入Secure World,TF-M的IPC层校验UID和缓冲区,然后调用安全存储分区执行Flash读写和加解密,最后返回结果。对业务代码来说,不感知这些细节就能获得安全能力,这正是PSA模型的优势。
调试时有个小技巧:如果psa_its_set返回PSA_ERROR_NOT_PERMITTED,九成是UID冲突或对象已被标记为WRITE_ONCE;如果返回PSA_ERROR_INSUFFICIENT_SPACE,则是存储区满了或单对象超过ITS_MAX_ASSET_SIZE。把这两种错误码的排查逻辑写进自检函数,能少踩很多坑。
6. 实战中的典型问题与排查方法
6.1 使用调试器时的访问权限问题
开发阶段最常踩的坑和调试器有关。当SPE运行在Secure状态、NSPE运行在Non-Secure状态时,调试器如果以非安全方式挂接,会发现安全地址全是不可访问的“黑洞”——读出来全0xFF,写不进去,单步也看不到安全代码。
解决思路分两种情况:
- 开发阶段:关闭TrustZone的调试隔离设置,或在调试探针中配置成安全调试模式(从Secure Debug接口访问)。具体方法因芯片而异,常见的是在连接调试器前先用Bootloader把调试认证状态设为“开放”。
- 现场调试故障:如果设备已经锁死安全调试口,就没法在线调试了。建议在固件中增加“安全日志”功能,把安全存储的失败原因以事件码形式记录在一个非安全侧可读取的RAM区域,通过串口或日志抽象层输出,这样即使不能断点调试也能定位问题。
6.2 GCM认证失败
GCM认证失败是运行阶段最常见的异常之一,表现为psa_its_get或psa_ps_get返回PSA_ERROR_INVALID_SIGNATURE。可能的原因按出现频率排:
| 可能原因 | 排查方向 |
|---|---|
| Nonce重复 | 检查Nonce生成逻辑是否在重启后被重置,是否在异常路径上重复使用 |
| 存储区被篡改 | 用安全固件导出的校验和比对对象区前后变化 |
| UID与AAD不匹配 | 确认写入时传入的UID和解密时校验AAD用的UID一致 |
| Flash写入顺序错误 | 检查是否在数据完全写入前就更新了状态标记 |
排查时,不要直接输出密钥或密文,而是把“认证失败时的元数据摘要”(UID、期望长度、读取偏移)和“失败错误码”以日志形式输出,这两个信息已经足够定位绝大多数问题。
6.3 Flash掉电导致的损坏
设备在写入存储对象时突然断电,恢复后对象读取失败。这个问题的根源不是加密算法,而是Flash写操作不是原子的。
我的处理方案是双管齐下:
- 在存储固件中实现“写后校验”:写完数据后立即读回比对,发现不一致就标记该对象为损坏,并回退到旧副本。
- 在应用层增加“恢复流程”:启动时扫描所有对象,发现损坏且无法恢复的对象,从备份区或云端重新拉取。
不过这里建议不要轻易在应用层做无限重试,否则可能把Flash刷坏。更稳妥的做法是限制重试次数,并给后续的升级流程留出操作空间。
6.4 调试端口与产品化选择的权衡
量产设备不可能向每个开发者都开放安全调试口,否则等于给攻击者开了一扇门。产品化阶段通常选择下面两种策略之一:
- 完全关闭调试口:芯片出厂后调试功能不可恢复,开发和维护全靠日志升级。适合对物理安全要求极高的产品,但要有极其完善的远程诊断机制。
- 证书认证调试:调试口默认关闭,但允许持有特定证书的调试器临时解锁。兼顾了产线调试和售后维护,但需要实现一个独立的调试认证服务,管理证书生命周期。
我的建议是,产品定义时先想清楚“谁来调试、在哪个阶段调试”,而不是等量产了再临时关闭调试口。很多项目就是没规划这一步,导致产品上市后发现产线也无法烧录、售后无法分析,只能回炉改板。
踩过几次坑之后,我最大的体会是:安全存储的设计一定要和Secure Boot、安全固件升级、密钥管理体系打包考虑,不能在项目后期“单独加一个加密模块”。前面提到的回滚保护、HUK派生、双槽写入,每一样单独拿出来都是标准件,但组合在一起并适配你具体MCU的Flash资源、启动流程和应用场景,才是真正的工作量所在。如果还是从零开始,建议从TF-M的SST模块跑起,再逐步替换为自己的业务密钥管理和存储格式,稳很多。