1. 为什么说安全存储是OP-TEE的“硬骨头”
接触过可信执行环境(TEE)的朋友都知道,OP-TEE(Open Portable Trusted Execution Environment)几乎是当前开源TEE实现的事实标准,它在ARM TrustZone硬件隔离之上跑一个微型内核,与普通世界(REE,Rich Execution Environment)完全隔离。但很多人一开始只关注“怎么跑通一个TA(Trusted Application)”,却忽略了真正要落地产品时必须面对的安全存储模块。
安全存储的核心问题很简单:你在安全世界里算出来的密钥、证书、关键业务数据,总得有个地方保存。但REE侧的内存和文件系统完全暴露在普通世界,谁都可以读。把数据丢到REE的文件系统里,TrustZone那道隔离墙就白砌了。而OP-TEE提供的安全存储(Secure Storage)就是专门解决这个问题的:由TEE内核自己管理一套加密存储方案,数据以密文形式落在REE的文件系统中,但密钥、加密流程、完整性校验全都控制在安全世界内部。
这套机制在你做金融支付、数字版权、设备身份认证、生物特征比对之类的场景里几乎是绕不开的。这篇文章我想从实际使用角度出发,把这套安全存储的架构逻辑、密钥管理方式、RPMB(Replay Protected Memory Block)和基于文件系统的实现差异、再到实际开发中容易踩的坑,一次性讲清楚。
2. 先搞懂基础架构:安全存储到底把数据放哪儿了
2.1 存储介质不是“安全”的,安全的是读写流程
初次接触OP-TEE安全存储的人,最容易产生一个误解:以为安全存储就是把数据放在一个物理上不可访问的专用硬件里。其实不是这样。
OP-TEE的安全存储在大多数开发板上,底层存储介质就是REE侧的常规文件系统——eMMC、SD卡、UFS这些。数据最终以一个名为<UUID>.sqlite或者一段二进制数据块的形式出现在REE的/data/tee目录下。听起来好像没什么防护?但关键在于:这些数据全部是密文,而且加密密钥不是简单写在某一处的,它由TEE内部生成、加密、再以特定的保护方式存起来。REE侧即使拿到这些文件,在没有TEE协助的情况下也无法解出明文。
存储介质本身不可信、不可保密,TEE要做的是让“读取”和“写入”这两个动作的完整链路处于安全世界的控制之下。这就是设计上的第一个核心认知:安全存储保护的是数据的机密性和完整性,而不是存储介质的物理隔离性。
2.2 数据分块与目录结构
实际使用OP-TEE安全存储时,你会通过GlobalPlatform的TEE_InvokeCommand调用TA的特定命令,或者直接在TA内部调用TEE内部API(Internal Core API)来操作。
OP-TEE维护了一个基于目录的安全存储空间,每个TA对应一个目录,目录名就是TA的UUID。例如有一个UUID为12345678-1234-1234-1234-123456789abc的TA,它在REE侧的文件路径会是类似下面这样:
/data/tee/12345678-1234-1234-1234-123456789abc在这个目录下,系统按1、2、3……这样的序号管理一个个存储对象文件,每个文件内部由多个块组成,块头部记录数据偏移、大小、加密后的内容以及完整性校验标签(Tag)。
这种按块存储的好处是支持随机读写,不用每次更新一小段数据就把整个对象重新加密写一遍。比如你的TA保存了一个配置项,只改其中4个字节,OP-TEE会把对应的数据块解密、修改、重新加密、再写回。当然代价就是元数据开销较大。
2.3 文件系统安全存储 vs RPMB
OP-TEE安全存储的后端实现有两种主流形态,在编译时会通过配置项区分:
| 后端类型 | 存储介质 | 物理隔离性 | 防回滚能力 | 适用场景 |
| FS(文件系统) | REE侧普通文件系统 | 无物理隔离,仅靠加密 | 弱(仅依靠防回滚计数器,需主动配置) | 大多数开发板、量产设备的默认形态 |
| RPMB | eMMC/UFS内置 RPMB分区 | 硬件隔离,专属访问命令 | 强(硬件防回滚计数器) | 对防回滚和防篡改要求极高的设备 |
如果你用的是RPMB后端,数据直接写在eMMC内部控制器的RPMB分区里,普通世界的操作系统根本不存在访问这个分区的合法路径(需要先通过安全世界建立认证密钥)。RPMB的每一次写操作都带有一个单调递增的计数器,这就让“把旧数据重新刷回来”这种回滚攻击变得几乎不可能。
但RPMB不是万能的,它对eMMC芯片有硬性要求,很多低价开发板上的eMMC并不支持RPMB功能,或者支持得不好。这时候就得出退而求其次的方案——基于文件系统的安全存储,再配合别的手段来提升防回滚能力。
3. 密钥层级与保护模型:数据加密的“总钥匙”从哪来
3.1 三级密钥体系
OP-TEE安全存储的安全性建立在多层密钥机制上,我习惯把它理解成一个“层层包裹”的结构。整体来看分三层:
首先是安全存储密钥(SSK,Secure Storage Key),这是最底层的一把“主钥匙”。它由TEE在第一次启动时随机生成,并写入到SoC内部的一次性可编程存储区(OTP/efuse)中,或者由你的启动流程在TEE启动早期注入。SSK的使命很单一:用于派生后续所有TA的安全存储密钥。这把钥匙理论上永不出TEE,也不直接参与业务数据加解密。
其次是TA安全存储密钥(TSK,TA Storage Key),每个TA都有一把独立的数据加密专用钥匙。它的派生过程是:TSK = HMAC-SHA256(SSK, TA_UUID)。也就是说,基于同一把SSK,用不同的TA_UUID作区分,算出每个TA“各自的钥匙”。
最后是文件加密密钥(FEK,File Encryption Key),它是在具体读写某个存储对象时由TEE临时生成或从已有密钥中解密出来的。实际加密文件数据块用的就是FEK,做到“每个文件/对象一把密钥”。
这样分层的好处非常明显:即便某一层密钥泄露,攻击者可以解的范围也被牢牢限制住。比如拿到了某TA的TSK,最多影响到这个TA自己的存储数据,其他TA依然是安全的。而FEK泄露,影响范围就缩小到一个具体文件。
3.2 密钥在自己的加密保护下
这里有一个容易绕晕的点:TSK本身存放在哪里?答案是用另一把密钥加密后再存到REE侧的存储文件里,这把用来保护TSK的“密钥加密密钥”就是从SSK派生出来的。它的存在形态是HMAC-SHA256(SSK, FEK加密key衍生材料)。
至于FEK,也不会以明文形式出现。每个安全存储对象的头信息中,保存了用TSK加密后的FEK密文。当你需要读写这个对象时,TEE启动流程:读取头文件 -> 用TSK解密出FEK -> 用FEK去解密具体的数据块。
这种“加密的钥匙套加密的钥匙”模式并不是OP-TEE独有,像Linux内核的dm-crypt、Android的硬件加密也都是类似的思路。理解了这个模型,后面看任何安全的存储方案都会轻松很多。
3.3 SSK存储更新:别忘了重新派生
有一个实操中很容易被忽略的点:SSK如果改变,所有依赖SSK派生的TSK、以及被TSK保护的所有数据文件,都会全部失效。所以在开发阶段,如果重新烧录了TEE固件或修改了OTP区域,你会突然发现之前存储的数据全变成“无解密的密文”。
这也是为什么量产设备上SSK一旦写入OTP,后续绝不轻易改动的根本原因。为了保证安全存储的长期可用性,你在产品设计阶段就得确定好SSK的生成策略和备份方案。
4. 深入实际:用OP-TEE API操作安全存储
4.1 接口总览
OP-TEE的TA通过GlobalPlatform内部API来调用安全存储能力。最核心的是下面几个函数:
TEE_Result TEE_CreatePersistentObject( TEE_ObjectHandle storage, void *objectID, uint32_t objectIDLen, uint32_t flags, TEE_ObjectHandle *object); TEE_Result TEE_OpenPersistentObject( TEE_ObjectHandle storage, void *objectID, uint32_t objectIDLen, uint32_t flags, TEE_ObjectHandle *object); TEE_Result TEE_WriteObject( TEE_ObjectHandle object, void *buffer, uint32_t size); TEE_Result TEE_ReadObject( TEE_ObjectHandle object, void *buffer, uint32_t size, uint32_t *count);作为开发者,你不需要关心数据块内部怎么加密、文件头怎么组织,你只需要按创建/打开 -> 读/写/更新 -> 关闭/删除这个套路跟API打交道。
4.2 完整示例:保存一个密钥到安全存储
我来写一个最小可用的示例。假设某TA要永久保存一份用于外部通信的对称密钥(比如一个32字节的AES-256密钥)。
#include <tee_internal_api.h> #include <tee_internal_api_extensions.h> #define KEY_OBJECT_ID "my_aes_key" #define KEY_SIZE 32 static TEE_ObjectHandle save_key_to_secure_storage(void) { TEE_Result res; TEE_ObjectHandle obj = TEE_HANDLE_NULL; uint8_t key_data[KEY_SIZE] = {0}; /* 实际使用中,密钥可能由TEE内部的随机数生成器产生 */ res = TEE_GenerateRandom(key_data, sizeof(key_data)); if (res != TEE_SUCCESS) return TEE_HANDLE_NULL; res = TEE_CreatePersistentObject( TEE_STORAGE_PRIVATE, KEY_OBJECT_ID, strlen(KEY_OBJECT_ID), TEE_DATA_FLAG_ACCESS_READ | TEE_DATA_FLAG_ACCESS_WRITE, &obj); if (res != TEE_SUCCESS) return TEE_HANDLE_NULL; if (TEE_GetObjectInfo(&obj) == TEE_SUCCESS && obj != TEE_HANDLE_NULL) { /* 实际不用这么写,仅为占位 */ } res = TEE_WriteObject(obj, key_data, KEY_SIZE); if (res != TEE_SUCCESS) { TEE_CloseObject(obj); return TEE_HANDLE_NULL; } /* 不要忘记关闭对象 */ TEE_CloseObject(obj); return obj; }这里有几个值得展开说的细节:
TEE_STORAGE_PRIVATE表示这是私密对象,只能被当前TA访问。除了PRIVATE,GlobalPlatform标准里还有TEE_STORAGE_REE等类型,但在安全存储语境下,我们几乎只用PRIVATE。- 如果对象已存在,
TEE_CreatePersistentObject会返回TEE_ERROR_ACCESS_CONFLICT或TEE_ERROR_FILE_EXISTS,需要先删除或改为打开已有对象。 - 对象ID可以是任意的字节序列,包括带有路径风格的名称。但注意它不等于文件路径,只是OP-TEE内部用来索引的标识符。
4.3 读取与删除数据
读取的套路也很好理解,打开对象、循环读取直到读完:
static TEE_Result read_key_from_secure_storage(uint8_t *out_buf, uint32_t buf_size) { TEE_Result res; TEE_ObjectHandle obj = TEE_HANDLE_NULL; uint32_t bytes_read = 0; uint64_t obj_size = 0; res = TEE_OpenPersistentObject( TEE_STORAGE_PRIVATE, KEY_OBJECT_ID, strlen(KEY_OBJECT_ID), TEE_DATA_FLAG_ACCESS_READ, &obj); if (res != TEE_SUCCESS) return res; res = TEE_GetObjectInfo(&obj, NULL); if (res != TEE_SUCCESS) { TEE_CloseObject(obj); return res; } /* 按需读取,如果buf不足,可分为多次读取 */ res = TEE_ReadObject(obj, out_buf, buf_size, &bytes_read); TEE_CloseObject(obj); if (res != TEE_SUCCESS) return res; return bytes_read > 0 ? TEE_SUCCESS : TEE_ERROR_NO_DATA; }删除对象:
TEE_Result res = TEE_CloseAndDeletePersistentObject1( TEE_STORAGE_PRIVATE, KEY_OBJECT_ID, strlen(KEY_OBJECT_ID)); if (res != TEE_SUCCESS) { /* 处理错误 */ }4.4 属性设置:对象不只是存数据
除了数据本身,GlobalPlatform API还允许给每个存储对象附加一些属性(Attribute),比如对象的创建时间、使用次数限制、访问权限位等。这些属性同样会加密存储在元数据中。在你设计一些关键业务流程时,可以善用属性来做“一次性令牌”“计数器”之类的功能,而不是自己另建一套存储。
5. 实操过程中的关键场景分析与选型权衡
5.1 场景一:开发板上的文件系统后端
这是最常见的情况。大多数开发者手头就是一块树莓派、某宝买的国产开发板或者是QEMU虚拟环境,eMMC没有可用RPMB或压根没eMMC。这种情况下,OP-TEE默认走文件系统后端。
这里有一个基本功要做:确保REE侧/data/tee目录的权限是安全的。虽然数据本身加密了,但攻击者如果能把你的加密文件删掉、改名、替换成旧版本,依然会带来一个问题——可用性被破坏以及可能的回滚攻击。
一个实用的加固方法是不要让REE的普通用户进程能直接访问这个目录。通常这个目录的属主是tee用户,通过SELinux或者简单的文件权限设置为0700。如果你的产品运行的是Android或者定制Linux,这一块要特别小心,别在发布版本里把权限开大了。
5.2 场景二:需要防回滚的产品
如果设备要防攻击者把存储数据恢复到旧版本(比如绕过远程升级强制回退到有漏洞的固件版本),那就要做防回滚设计。
在文件系统后端下,OP-TEE提供了一个“防回滚计数器”机制:它会在REE侧保存一个计数器值,每次写对象时计数器递增,并且这个计数值也参与加密完整性校验。如果攻击者把文件替换成旧的快照,计数值就会小于当前值,TEE会发现校验失败进而拒绝使用这些数据。但这个计数器本身存在REE侧,理论上攻击者把整个目录连同计数器一起“还原”即可。
所以如果你的攻击模型里有“攻击者能完整复制并还原REE文件系统”的前提,最好上RPMB后端。RPMB的计数器由eMMC控制器自己维护,固件之外根本改不了,这才是真正可靠的防回滚。
5.3 场景三:大块数据存储
安全存储并不是设计用来存大文件的。因为每一次读写都要经过加解密、哈希校验、RPMB慢速写入等流程,性能和寿命都不适合承载几十MB的视频、日志等数据。
如果确实有安全存储大块数据的需求,一种常见做法是:安全存储仅保存一个用于数据加密的密钥,业务数据本身用这个密钥以AES-GCM模式加密后,存到REE侧普通文件系统里。读取时,TA解密密钥,再交给REE侧协助解密或直接由TA解密。
这本质上就是“信封加密”。Key在安全世界里管,Ciphertext在普通世界里放,两者分离。真正要保护的核心资产——密钥——始终不离开TEE,数据处理性能又不受TEE存储瓶颈拖累。
6. 调试安全存储时常见的坑与排查思路
6.1 反复重启后数据丢失
很多人在开发阶段遇到一个非常诡异的现象:TA在QEMU或开发板上创建了安全存储对象,能正常读,但一重启,数据没了。
排查思路很简单:先确认OP-TEE是否配置了CFG_RPMB_FS或CFG_REE_FS。如果用的是QEMU,默认可能有CFG_RPMB_FS=y,而QEMU里模拟的RPMB并不真正落盘,重启即失。换成CFG_REE_FS=y后,数据写到host上的镜像文件里,重启就还在了。
6.2 TA更新了UUID导致旧数据无法访问
安全存储对象在目录层是按TA UUID区分的。如果你改了TA的UUID,新UUID下的TA是无法访问旧UUID下存储的数据的,因为TSK和目录都不一样。
解决方案有两种:要么保持UUID不变,只改版本号并做数据迁移逻辑;要么在TA内部做一次对象迁移——打开旧UUID下的对象,读出来,写到新UUID下。要注意的是,旧UUID下的TA代码可能已经不存在了,所以迁移逻辑通常要放在REE侧做远程管理,或者用一个专门的“迁移TA”来代管。
6.3 写入失败TEE_ERROR_STORAGE_NO_SPACE
这个错误发生在一个数据块都写不进去的时候。原因无外乎两种:REE侧存加密文件的磁盘满了;或者OP-TEE内部的对象数达到上限。
如果确认REE侧空间足够,那多半是OP-TEE内部元数据开销比你想象的大。每个块头部还有额外的IV、Tag等字段,块又存在碎片空间。解决方法是给安全存储预留足够空间,或者通过调整CFG_TA_STORE_MAX_OBJ_SIZE和块大小控制在合理范围。另外,对象数量默认有上限,一般为CFG_TA_STORE_MAX_OBJ_CNT,超过也要清理旧对象。
6.4 对象上的访问权限标识没搞明白
使用TEE_CreatePersistentObject创建对象时,flags参数指定了访问属性。如果创建时只给了TEE_DATA_FLAG_ACCESS_WRITE,之后想读就会得到TEE_ERROR_ACCESS_DENIED。这个错误比较隐蔽,因为你的代码可能分开写在初始化函数和业务函数里。我见过不止一个同事因为创建和读取时的flags不一致,排查半天最后发现只是属性没对上。
建议在项目中给存储对象定义一套统一的flag宏,创建、打开、更新全部按这个宏走,避免各处维护不一致的权限组合。
6.5 防回滚计数器溢出或者丢失
使用RPMB后端时,如果RPMB区域的写计数达到最大值,设备会拒绝继续写入。这种情况在真机上非常罕见,但在QEMU模拟环境和早期开发板上可能因为RPMB Key未正确烧录而出现类似问题。排查时先确认RPMB Key是否存在,是否与固件中烧录的一致。
7. 安全存储的性能与替代方案权衡
OP-TEE官方文档和安全社区都有一个共识:安全存储的读写性能远低于REE普通文件系统,尤其是在文件系统后端下,每一笔写入都要经过TA调用、TEE内核加解密、再落盘到REE,性能可能只有几MB/s到几十MB/s量级,与直接读写存储介质完全不是一个数量级。
因此,在设计安全存储时你其实是在做一道权衡题:
- 对性能极其敏感的数据,考虑只把密钥或核心元数据放进安全存储,业务大数据用信封加密存在REE侧。
- 含有敏感信息且数据量小的场景(证书、PIN码、设备密钥、许可证、用户身份信息),安全存储是非常合适的选择。
- 如果对防回滚和防篡改要求极高,优先选RPMB后端,否则文件系统后端配合访问权限控制即可满足大多数需求。
在我实际做过的几个项目里,最常见、最合理的划分方式是:
- 在TEE内部用安全存储保存设备主密钥、签名公钥、根证书;
- 业务数据(比如支付令牌、离线消息)用这些密钥做AES-GCM加密,放到REE侧;
- 定期做密钥轮换,安全存储只保存当前生效的密钥版本和旧密钥的加密副本。
这样既控制了安全存储的容量和性能压力,又不会让核心机密落到REE的裸数据里。
8. 开发阶段如何验证安全存储是否真的“安全”
写完了TA、调通了API,怎么验证安全存储真的起到了保护作用?我在项目里通常做三件事。
第一件:在REE侧直接查看/data/tee下的文件内容,确认看不到明文。如果你能看到明文,说明要么加密逻辑没生效,要么后端配置被改成了直通模式,这是严重的安全事故。
第二件:篡改测试。手动修改某个安全存储文件里的几个字节,再让TA去读取数据,观察是否出现TEE_ERROR_MAC_INVALID或TEE_ERROR_CORRUPT_OBJECT的报错。如果读取依然成功且数据正确,那基本可以断定完整性校验没起作用。
第三件:回滚测试。在修改数据前,备份整个/data/tee目录。修改数据后,把目录还原回去,看看TA还能不能正常读取到旧数据。如果启用了防回滚,此时应该检测到计数器比当前值小,拒绝这些数据。如果在文件系统后端下配置了防回滚而测试发现回滚成功,那你要小心了——你配置了但没有真正使能,或者REE侧计数器存的地方没有被正确保护。
这三件事做完,你的安全存储才算“验收过关”。
9. 从安全存储延展出去的思考
OP-TEE安全存储看起来只是一个模块,但它背后牵涉的设计原则,其实可以推广到整个TEE生态。密钥分层、完整性校验、防回滚、最小权限……这些概念不只是OP-TEE所独有,Android的Keystore、iOS的Keychain、各种安全芯片里的文件系统,本质上都是同一套思路。
后面如果你要继续深入,有几个方向值得跟进:一个是RPMB驱动在复杂场景下的调试经验,另一个是GPD TEE Internal Core API规范里关于object attr的各种扩展玩法,还有安全存储与TA签名校验结合时的启动信任链设计。每个方向拆开都能再写一篇长文,今天先把安全存储本身讲透。
读到这里如果你正打算在项目里落地OP-TEE安全存储,建议先把开发环境跑起来,用QEMU或一块开发板把文件系统后端、RPMB后端各验证一遍,再结合你的具体业务决定密钥分层方案。踩过一遍上述那些坑,你就能比大多数人更理解这套机制了。