开发一个TEE简单存储模块
本文在 OPTEE提供的开源代码 的基础上,讲解了CA和TA的开发细节,并添加了可执行程序、JNI、系统服务等内容,实现了一个相对完整的TEE模块;
1 背景知识
TEE是现代操作系统内的一种安全基础设施,其主要特征有:
- 安全 —— 从芯片架构上支持REE和TEE的隔离,CA内部支持安全存储和密码学算法,对TEE数据的访问严格受限;
- 不易失 ——重启/OTA/恢复出厂设置不丢失;有些厂家能做到烧版本数据也不丢失(不同厂家不一样,取决于分区表和烧录工具的行为);
1.1 术语
- TrustZone: ARM提出的一种安全解决方案,通过独立的安全操作系统和硬件虚拟化技术,提供可信运行环境;
- GlobalPlatform: GlobalPlatform(GP)是跨行业的国际标准组织,致力于开发、制定并发布安全芯片的技术标准,以促进多应用产业环境的管理 及其安全、可互操作的业务部署。作为一个国际标准组织,其工作重心主要集中在安全单元(SE)、可信执行环境(TEE)和系统消息(Mobile Messaging)等领域,其成熟的技术规范是建立端到端可信业务解决方案的工具,并服务于产业环境的多个成员,支持多种商业模式。GP是全球基于安全芯片的安全基础设施统一的标准的制定者。
- OPTEE: 基于ARM TrustZone技术,对GlobalPlatform的TEE规范的一种开源实现;
- TDK: TEE Develop Kit,Amlogic的文档里有时候会用这个词,指的是厂家提供的可以运行在自身平台上的开发工具组,通常都是基于OPTEE的;
- REE: Rich Execution Environment,通常指的是Linux、Android等操作系统环境,这里的Rich指的是可以操作的各种资源更丰富;
- TEE: Trusted Execution Environment 可信运行环境;
- CA: 有两个语义:(1)TEE的上下文中,指的是Client App,即在REE环境运行的一些不受保护的应用,包含so库、可执行程序、apk等;(2)密码学的上下文中,指的是证书授权中心 Certificate Authority;
- TA: Trusted App,即运行在TOS内的一个应用程序;
- TOS: Trust OS,即运行在TEE内的一个微型OS,主要功能是提供TEE内的任务调度、资源分配、以及TEE环境的系统调用;
- 证书: 将CA颁发给某个持有者的用户公钥,用CA中心的私钥进行签名后的文件;
1.2 参考文档
OPTEE的可以从 GlobalPlatform的API文档 以及 readthedocs 获得;
其他各自平台的请向芯片供应商咨询。
2 CA和TA
一个最简单的TEE功能,至少包括CA和TA两个互相关联的模块;
我们先简单介绍一下CA和TA之间的交互流程,简单讲解一下TEE开发的基本过程;
2.1 CA和TA之间的交互
上面的TrustZone-OPTEE架构图,很清晰地展示了CA和TA分别处于两个世界的客观现实,那么在这种隔离状态下,他们之间的交互怎样完成呢?
问题一:既然REE存在多个CA,TEE/TOS内存在多个TA,那么,CA怎么找到对应自己的TA?
答案一: 不同的TA用UUID来区别,TA通过头文件定义自己的UUID,CA必须include对应的头文件,以便获取UUID;
非常不建议自己编造UUID,可以通过类似 https://www.uuidgenerator.net/ 这样的工具生成;
需要特别强调的是,这个UUID是TA的唯一标志,所以属于产品机密,绝对不要泄露。本文统一以"YOUR_UUID.ta"来代替TA的名字。
问题二:Priviledged Mode的系统资源必然不是无限的,那么CA和TA之间的通信,怎样避免长期占用资源?
答案二: TEE用Context和Session的机制来管理CA和TA之间的交互,CA需要初始化一份TEEC_Context,继而用TEEC_Context+UUID来获得TEEC_Session,然后才能执行对TA的操作。
操作完毕之后,要立即将TEEC_Session和TEEC_Context关闭(对称地,创建时的顺序是先TEEC_Context后TEEC_Session,关闭时顺序相反,先关闭TEEC_Session,再关闭TEEC_Context);
而且,据我们在实际开发中发现,同一个TA,不能同时间有两份TEEC_Session,否则设备会直接重启;这可能是OPTEE为了安全和性能的考虑;
问题三:一组CA和TA内,很可能是存在多个功能函数的,那么CA怎样告诉TA自己想要执行哪个操作?
答案三: CA调用TA的函数时,需要指定commandID;
commandID通常是一个非负整数,TA开发者确保当前TA范畴内,每个函数有唯一的commandID;
问题四:函数的调用必然涉及到参数和返回值,CA怎样将参数告知TA,TA怎样将返回值(执行结果、读文件结果等)返回给CA?
答案四: CA调用TA函数时,除了需要指定上面第三个问题里的commandID外,还需要同时指定一个TEEC_Operation对象,这个TEEC_Operation对象是对待执行的操作的抽象,其核心成员是一个四个元素的TEEC_Parameter数组;
TEEC_Parameter是union类型,可以存储TEEC_TempMemoryReference,TEEC_RegisteredMemoryReference,或者TEEC_Value类型的数据;
如果需要CA传递内存引用给TA,需要使用TEEC_TempMemoryReference;
如果需要TA返回数据给CA,例如读取文件内容,则需要使用 TEEC_RegisteredMemoryReference,其指向TEEC_SharedMemory对象;TEEC_Value用于携带简单的int类型数据;
通过指定四个元素的TEEC_Parameter数组的每个元素的input/output/inout类型,可以定义是否需要携带数据从TA返回CA;
2.1.1 CA和TA之间交互的详细流程图
2.2 CA开发
2.2.1 CA端代码路径
我们将自己开发的模块命名为 mytee,代码路径如下:
源文件路径 | 功能/内容 |
---|---|
host/mytee/mytee_tee_ca.h | 头文件,主要是LOGE/LOGD等日志函数、read/write/delete_secure_object三个函数声明、以及JNI需要的函数声明等; |
host/mytee/mytee_tee_ca.c | 实际功能代码,包含CA端与TA端交互相关的接口,实际读写删TEE的代码,以及支持JNI的代码,支持编译为可执行程序的main函数; |
Android.mk | 所有CA程序的mk文件,这里添加了编译为so和可执行程序的make脚本,不同平台的编译脚本各有不同,这里不关注这些差异 |
2.2.2 CA端常用数据结构和函数
头文件 tee_client_api.h 描述了当前平台支持哪些API,不同平台上其路径可能有差异;
做任何CA开发之前,请务必将这个api文档一字不落地仔细阅读一下;
tee_client_api.h 内常用到的重要类型和结构体等定义有:
|
|
2.2.3 CA调用TA的必备接口
首先看一下CA与TA交互必备的几个接口,按功能大体分成3类:
创建或者释放 Context
TEEC_InitializeContext:初始化一个TEE Context,所有TEE操作的第一步;
TEEC_FinalizeContext:释放TEE Context,所有TEE操作的最后一步;
打开或者关闭 Session
TEEC_OpenSession:会话打开函数;当客户端应用执行 TEEC_OpenSession 时,安全OS会执行对应TA的 TA_OpenSessionEntryPoint ;如果此时TA实例未创建时,需要调用 TA_CreateEntryPoint ;
TEEC_CloseSession:会话关闭函数;当客户端应用执行 TEEC_CloseSession 时,安全OS会执行对应TA的 TA_CloseSessionEntryPoint ;如果客户端应用关闭的会话为此TA的最后一个会话,则安全OS核心框架会执行 TA_DestroyEntryPoint 函数销毁TA实例;
CA调用TA的具体功能
TEEC_InvokeCommand:使用会话和命令ID通知指定TA执行与命令ID匹配的操作;
2.2.4 CA开发与集成的详细步骤
写每一个具体的功能,以最常用的安全存储为例,至少要包含这三个功能:
从TEE读取文件
相比OPTEE的版本,我们这里增加了一个 size_t 类型的参数,用于携带回来“文件的实际大小”这个值*,知道文件实际大小,则读取成功时便对文件做实际处理,因为buff_sz太小而失败时,调用者可以调整buffer;
(CA无法知晓某个文件的大小,但TA是知道的,如果TA通过value_out的形式告知了CA,则CA就能进一步告知上层调用者;OPTEE的样例代码里,TA就是按照这个逻辑处理的,但CA并没有关注文件实际大小这个信息,我们这里给加上了)
1 2 3 4 5
// id: TEE内的文件id // buff: 存储读取的内容的缓冲区 // buff_sz: 从TEE读取文件时,必须显式指定缓冲区的大小,且缓冲区大小不能小于待读取的文件大小 // file_sz: 文件实际大小,由TA返回给CA TEEC_Result read_secure_object(const char *id, char *buff, size_t buff_sz, size_t* file_sz);
往TEE内写入文件
1 2 3 4
// id: TEE内的文件id // data: 存储待写入的内容的缓冲区 // data_len: 写缓冲区内多少字节的内容到TEE TEEC_Result write_secure_object(const char *id, char *data, size_t data_len);
从TEE删除文件
1 2
// id: TEE内的文件id TEEC_Result delete_secure_object(const char *id);
每个功能方法内部,都需要依次做如下6个步骤:
下面代码片段来自 read_secure_object 函数(为了体现每一步用到了哪些变量,将部分变量从函数前面的定义位置挪到了第一次使用前,后续代码也有类似排版,望读者知晓,后面不再赘述)
写文件和删除文件的代码相对比较简单,OPTEE样例代码已经足够了,这里就不再重复贴代码了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90
// Step1: 初始化Context // name默认只支持配置为NULL,第二个入参即为初始化后的TEEC_Context // Step2: 打开连接到指定TA的一个TEEC_Session // 第一个参数乃是刚才获取的TEEC_Context,第二个参数即为获取的TEEC_Session, // 第三个参数非常重要,是TA的UUID, // UUID在TA的头文件中定义,在CA的c文件中必须include对应TA的定义UUID的头文件; // 考虑到每次读写删都需要上面两个步骤,所以OPTEE的样例代码是把这两步抽取到了一个独立的函数里 TEEC_Result prepare_tee_session(TEEC_Context* ctx, TEEC_Session* sess) { TEEC_UUID uuid = TA_SECURE_STORAGE_UUID; uint32_t origin; TEEC_Result res; /* Initialize a context connecting us to the TEE */ res = TEEC_InitializeContext(NULL, ctx); if (res != TEEC_SUCCESS) { errx(1, "TEEC_InitializeContext failed with code 0x%x", res); LOGE("TEEC_InitializeContext failed with code 0x%x", res); return res; } /* Open a session with the TA */ res = TEEC_OpenSession(ctx, sess, &uuid, TEEC_LOGIN_PUBLIC, NULL, NULL, &origin); if (res != TEEC_SUCCESS) { errx(1, "TEEC_Opensession failed with code 0x%x origin 0x%x", res, origin); LOGE("TEEC_Opensession failed with code 0x%x origin 0x%x", res, origin); return res; } return res; } // Step3: 初始化 TEEC_Operation 对象, // 如果需要在CA和TA之间传输数据(例如读TEE),还需要初始化TEEC_SharedMemory对象 TEEC_SharedMemory sm; sm.size = buff_sz; sm.flags = TEEC_MEM_INPUT | TEEC_MEM_OUTPUT; res = TEEC_AllocateSharedMemory(&ctx, &sm); if (res != TEEC_SUCCESS) { printf("AllocateSharedMemory ERR! res= 0x%x\n", res); return res; } TEEC_Operation op; memset(&op, 0, sizeof(TEEC_Operation)); // TEEC_PARAM_TYPES是一个宏,详见tee_client_api.h op.paramTypes = TEEC_PARAM_TYPES( TEEC_MEMREF_TEMP_INPUT, TEEC_MEMREF_PARTIAL_OUTPUT, TEEC_NONE, TEEC_NONE); op.params[0].tmpref.size = id_len; op.params[0].tmpref.buffer = id; // 第二个参数关联到 SharedMemory,用于返回读取的内容,OPTEE代码中使用TEEC_MEMREF_TEMP_OUTPUT 类型携带输出, // 且直接使用了read_secure_object 函数的入参作为 tmpref.buffer,笔者实测,某些平台上这样无法获得文件内容, // 而使用memref关联到CA自己通过TEEC_AllocateSharedMemory 申请到的内存,是在各个平台都可用的方案; // OPTEE的做法:https://github.com/linaro-swg/optee_examples/blob/master/secure_storage/host/main.c op.params[1].memref.parent = &sm; op.params[1].memref.offset = 0; op.params[1].memref.size = sm.size; // Step4: 通过TEEC_InvokeCommand调用某一个TA内定义好的函数,并处理返回值 // 第二个参数commandID是函数的唯一标志,需要CA和TA约定好 res = TEEC_InvokeCommand(&sess, TA_SECURE_STORAGE_CMD_READ_RAW, &op, &origin); switch (res) { case TEEC_SUCCESS: LOGD("TEE read success, file size=%zd", op.params[1].memref.size); // op.params[1].memref.size 携带了实际读取到了多少数据,详见TA的代码 *file_sz = op.params[1].memref.size; if(op.params[1].memref.size > 0) { // 从TEEC_SharedMemory里面获取读到的内容 memcpy(buff, sm.buffer, op.params[1].memref.size); } break; case TEEC_ERROR_SHORT_BUFFER: // 如果因为buffer太小而失败,也将文件实际大小返回给调用者 *file_sz = op.params[1].memref.size; LOGE("TEE read fail: Short Buffer, file size=%zd", op.params[1].memref.size); break; case TEEC_ERROR_ITEM_NOT_FOUND: LOGE("TEE read fail: Item Not Found"); break; default: LOGE("Command READ_RAW failed: 0x%x / %u", res, origin); } // Step5: 关闭TEEC_Session // Step6: 终结TEEC_Context // 关闭时的顺序正好跟打开时相反,先关闭session,后关闭context,OPTEE也将这两步抽取出来独立做了一个函数 void terminate_tee_session(TEEC_Context* ctx, TEEC_Session* sess) { TEEC_CloseSession(sess); TEEC_FinalizeContext(ctx); }
如果需要编译为可执行程序,还需要写一个main函数(事实上我们都是先写main函数来直接本地调试);
如果需要需要支持JNI(这几乎是必须的),还需要实现JNI相关的函数;
可执行程序和JNI的内容,我们放到下一章节讲;
编译,我们通常将CA编译为so库,这里so库的名字是libmytee.so,按照需要可能需要编译32位和64位两个;
考虑到几乎所有平台上,CA的编译结果都在/vendor/lib、/vendor/lib64、/vendor/bin 目录下;而Android NDK的开发规范要求,vendor可以依赖system,而system不能依赖vendor;所以正式集成时,通常的做法是将编译结果复制一份,作为prebuilt来集成到产品的/system/lib、/system/lib64、/system/bin下面;
由于几乎所有的CA都会依赖于libteec.so,这个libteec可以被认为是CA的运行依赖库,所以需要将原生代码编译出来的/vendor/lib/libteec.so、/vendor/lib64/libteec.so 复制一份,以prebuilt的形式集成到 /system/lib 和 /system/lib64 里面;
做一次就可以了,以后更新CA和TA不需要再更新libteec.so;后续如果升级底版本,相当于teec有可能改动,需要重新更新一次;
将 libmytee.so 和 libteec.so 添加到系统的public-library列表里面,位置在 Overlay/system/core/rootdir/etc/public.libraries.android.txt
当然,这个也是添加一次后,以后就基本上不需要更新了,除非增加了新的so库作为CA端;
至此,CA端的工作基本完毕;
2.3 TA开发
2.3.1 TA端代码路径
源文件的路径和功能:
源文件路径 | 功能/内容 |
---|---|
ta/Makefile | 在TA_DIRS 变量中添加了一行mytee,告知编译脚本还得编译mytee这个TA; |
ta/mytee/Android.mk | 指定 local_module=YOUR_UUID.ta ,调用 BUILD_OPTEE_MK |
ta/mytee/Makefile | 指定 BINARY=YOUR_UUID |
ta/mytee/sub.mk | 指定全局include目录(即可供CA端include的),指定源文件列表 |
ta/mytee/include/secure_storage_ta.h | 供CA端include,主要是指定了TA的UUID和TA支持的所有commandID列表 |
ta/mytee/secure_storage_ta.c | 实现具体功能,以及TA必备的5个流程接口(TA的创建和销毁,开关session、调用具体功能) |
ta/mytee/user_ta_header_defines.h | 定义TOS关心的一些宏,包括 TA_UUID , TA_FLAGS , TA_STACK_SIZE , TA_DATA_SIZE 等 |
上述源代码列表中,除了 include/secure_storage_ta.h 和 secure_storage_ta.c 这两个文件名可以由TA开发者自定义外,其他文件名都是固定的;
实际工作中,底版本都会自带有helloworld的TA,直接复制一份,然后修改UUID、C文件和头文件的名字等,然后就可以立即上手做开发了,不用太关心基础设施的细节;
2.3.2 TA端重要数据结构和函数
GlobalPlatform的API文档 非常详细,务必下载一份随时查阅。
TA端的API包括 tee_api.h 和 tee_api_types.h ;
下面是tee_api_types.h中定义的最常用的数据结构:
|
|
tee_api.h中定义的一些涉及到读、写、删的最常用的函数:
再次强调一下,GlobalPlatform的API文档 非常详细,包含下面列举出的所有函数的详细解释,你值得拥有!
部分函数名字中带有数字1,原因是不带数字的旧版本已经废弃不建议使用了;
|
|
2.3.3 TA响应CA的必备接口
首先看一下做为TA必须实现的5个流程接口,按照功能可以分成3个类别:
TA的创建和销毁
TA_CreateEntryPoint:安全服务的构造函数接口,该接口在客户端应用第一次打开会话并创建TA实例时得到调用。如果此函数执行失败,则该TA实例构建失败。
TA_DestroyEntryPoint:安全服务的析构函数接口,该接口在客户端应用关闭会话并销毁TA实例时得到调用。
通常这俩函数不需要做什么特殊处理;
打开和关闭Session
TA_OpenSessionEntryPoint:当客户端应用打开会话时,安全OS会调用对应TA的此接口在安全世界创建一个会话。
TA_CloseSessionEntryPoint:当客户端应用关闭会话时,安全OS会调用对应TA的此接口在安全世界关闭会话。
通常这俩函数不需要做什么特殊处理;
根据CommandID,调用具体的函数
TA_InvokeCommandEntryPoint:这个函数的参数列表是固定的,command、param_types、TEE_Param数组都是来自于CA端调用
TEEC_InvokeCommand
函数时的参数;TA需要实现的是,对于每个commandID,将调用派发到不同的实际功能函数,通常switch-case之类就足够了;
TA的这套调用规范,也决定了TA内具体干活的函数的入参必然要包括
uint32_t param_types
和TEE_Param params[4]
这两项;1 2 3 4 5 6 7 8 9 10 11 12 13 14
TEE_Result TA_InvokeCommandEntryPoint(void __unused *session, uint32_t command, uint32_t param_types, TEE_Param params[4]) { switch (command) { case TA_SECURE_STORAGE_CMD_WRITE_RAW: return create_raw_object(param_types, params); case TA_SECURE_STORAGE_CMD_READ_RAW: return read_raw_object(param_types, params); case TA_SECURE_STORAGE_CMD_DELETE: return delete_object(param_types, params); default: EMSG("Command ID 0x%x is not supported", command); return TEE_ERROR_NOT_SUPPORTED; } }
将TA_InvokeCommandEntryPoint 的派发逻辑写完后,接下来就是每个具体函数需要做的事情了。
下一节我们将依次介绍读写删每个功能的重点;
2.3.4 TA开发与集成的详细步骤
处理入参
为了安全稳定,建议任何函数内,第一步先检查参数类型,仍然以读文件为例:
类型检查与CA端是对应的,可以回过头看一下 “CA开发与集成的详细步骤”中的 “Step3: 初始化 TEEC_Operation 对象”;
1 2 3 4 5 6 7 8 9 10
// 第1个参数是携带了文件id,仅用于输入,所以类型是 MEMREF_INPUT // 第2个参数携带读取到的内容,所以类型是 MEMREF_OUTPUT const uint32_t exp_param_types = TEE_PARAM_TYPES( TEE_PARAM_TYPE_MEMREF_INPUT, TEE_PARAM_TYPE_MEMREF_OUTPUT, TEE_PARAM_TYPE_NONE, TEE_PARAM_TYPE_NONE); if (param_types != exp_param_types) { printf(" ~~~~mytee_ta~~~~ read_raw_object TEE_ERROR_BAD_PARAMETERS"); return TEE_ERROR_BAD_PARAMETERS; }
从入参TEE_Param params[4] 中获取参数时,如果参数是value类型,则直接使用value.a或value.b即可;
如果参数是MemoryReference类型(TEEC_TempMemoryReference或TEEC_RegisteredMemoryReference),则使用其值之前,必须先用TEE_Malloc申请内存,然后用TEE_MemMove的方法复制MemoryReference的buffer或者parent的内容到刚申请的内存中;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
char *obj_id; size_t obj_id_sz; char *data; size_t data_sz; // 通过params[0].memref.size 获取内存区域大小,并分配指定的空间供拷贝 obj_id_sz = params[0].memref.size; obj_id = TEE_Malloc(obj_id_sz, 0); if (!obj_id) return TEE_ERROR_OUT_OF_MEMORY; // 将CA通过params[0]传来的数据拷贝到TA内obj_id TEE_MemMove(obj_id, (char *)params[0].memref.buffer, obj_id_sz); // 从params[1]获取CA使用的buff_sz大小,并在TA内分配指定的空间备用 // 注意,params[1].memref.size 后面被复用, // 当CA的buffer太小时,用于存储文件的实际大小;当读取成功时,存储实际读取的数据大小(实际上也等于文件大小) data_sz = params[1].memref.size; data = TEE_Malloc(buff_sz, 0); if (!data) return TEE_ERROR_OUT_OF_MEMORY;
特别注意,所有使用TEE_Malloc申请的内存,在函数退出前,必须用TEE_Free释放内存;
关于CA和TA中内存的处理,详见下面的第2.4节 内存相关注意事项;
从TEE内读取文件
见TA端的函数
static TEE_Result read_raw_object(uint32_t param_types, TEE_Param params[4])
首先用函数
TEE_OpenPersistentObject
打开文件,然后用函数TEE_GetObjectInfo1
获取TEE_ObjectInfo
,包含文件的大小等,并确认指定的缓冲区是否足够读取文件;1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
TEE_ObjectHandle object; TEE_ObjectInfo object_info; res = TEE_OpenPersistentObject(TEE_STORAGE_PRIVATE, obj_id, obj_id_sz, TEE_DATA_FLAG_ACCESS_READ | TEE_DATA_FLAG_SHARE_READ, &object); if (res != TEE_SUCCESS) { EMSG("Failed to open persistent object, res=0x%08x", res); TEE_Free(obj_id); return res; } res = TEE_GetObjectInfo1(object, &object_info); if (res != TEE_SUCCESS) { EMSG("Failed to create persistent object, res=0x%08x", res); goto exit; } // 如果发现CA提供的buffer太小, // 则将文件的实际大小写到params[1].memref.size 并返回错误码 TEE_ERROR_SHORT_BUFFER if (object_info.dataSize > buff_sz) { // Provided buffer is too short. Return the expected size together with status "short buffer" params[1].memref.size = object_info.dataSize; res = TEE_ERROR_SHORT_BUFFER; goto exit; }
然后用函数
TEE_ReadObjectData
读取文件内容,第二个参数 data 是存储内容的缓冲区,最后一个参数可以携带实际读取的字节数;如果读取成功,则可以将实际读取的字节数通过复用params[1].memref.size返回给CA端调用者;1 2 3 4 5 6 7 8
res = TEE_ReadObjectData(object, data, object_info.dataSize, &read_bytes); if (res != TEE_SUCCESS || read_bytes != object_info.dataSize) { EMSG("TEE_ReadObjectData failed 0x%08x, read %" PRIu32 " over %u", res, read_bytes, object_info.dataSize); goto exit; } // 如果读取成功,则通过params[1].memref.size 携带实际读取的字节数(实际上必然等于文件大小) params[1].memref.size = read_bytes;
然后,先用函数
TEE_CheckMemoryAccessRights
检查一下CA端指定的携带返回值的参数是否用写权限,如果可写,则用TEE_MemMove
的方式将读取到的 data 中的内容复制到CA指定的区域;1 2 3 4 5 6 7
res = TEE_CheckMemoryAccessRights(TEE_MEMORY_ACCESS_WRITE | TEE_MEMORY_ACCESS_ANY_OWNER, params[1].memref.buffer, params[1].memref.size); if (res != TEE_SUCCESS) { EMSG("CheckMemoryAccessRights ERR: 0x%x.", res); return res; } TEE_MemMove(params[1].memref.buffer, data, read_bytes);
最后的最后,不要忘了释放内存;
1 2 3 4 5 6
exit: TEE_CloseObject(object); TEE_Free(data); TEE_Free(obj_id); return res; }
向TEE内写入文件
见TA端的函数
static TEE_Result create_raw_object(uint32_t param_types, TEE_Param params[4])
从参数中读取内容的方法与读文件时类似:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
char *obj_id; size_t obj_id_sz; char *data; size_t data_sz; obj_id_sz = params[0].memref.size; obj_id = TEE_Malloc(obj_id_sz, 0); if (!obj_id) return TEE_ERROR_OUT_OF_MEMORY; TEE_MemMove(obj_id, params[0].memref.buffer, obj_id_sz); data_sz = params[1].memref.size; data = TEE_Malloc(data_sz, 0); if (!data) return TEE_ERROR_OUT_OF_MEMORY; TEE_MemMove(data, params[1].memref.buffer, data_sz);
然后,调用函数
TEE_CreatePersistentObject
创建一个TEE_ObjectHandle
类型的对象;注意其flags参数的配置;1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
obj_data_flag = TEE_DATA_FLAG_ACCESS_READ /* 允许创建后读 */ | TEE_DATA_FLAG_ACCESS_WRITE /* 允许创建后写 */ | TEE_DATA_FLAG_ACCESS_WRITE_META /* 允许创建后删除或者重命名 */ | TEE_DATA_FLAG_OVERWRITE; /* 原来存在则直接覆盖掉原来的内容 */ res = TEE_CreatePersistentObject(TEE_STORAGE_PRIVATE, obj_id, obj_id_sz, obj_data_flag, TEE_HANDLE_NULL, NULL, 0, &object); if (res != TEE_SUCCESS) { EMSG("TEE_CreatePersistentObject failed 0x%08x", res); TEE_Free(obj_id); return res; }
然后用函数
TEE_SeekObjectData
,seek到文件开头;1 2 3 4 5 6
res = TEE_SeekObjectData(object, 0, TEE_DATA_SEEK_SET); if (res != TEE_SUCCESS) { EMSG("SeekObjectData ERR: 0x%x.", res); TEE_Free(obj_id); return res; }
然后用
TEE_WriteObjectData
写入数据到TEE_ObjectHandle
对象中,注意数据缓冲区必须是TEE_MemMove
函数copy的目的地址;写入成功则调用
TEE_CloseObject
关闭文件对象,写入失败的话,需要用TEE_CloseAndDeletePersistentObject1
来关闭并删除写入错误的文件(TEE内的存储空间有限,避免浪费);1 2 3 4 5 6 7 8 9
res = TEE_WriteObjectData(object, data, data_sz); if (res != TEE_SUCCESS) { EMSG("TEE_WriteObjectData failed 0x%08x", res); // 如果写文件失败,则这里必须记得删除刚刚创建的持久文件,否则会造成TEE存储空间泄露 TEE_CloseAndDeletePersistentObject1(object); } else { TEE_CloseObject(object); }
最后的最后,不要忘了释放内存;代码这里就不赘述了;
删除TEE内的文件
见TA端的函数
static TEE_Result delete_object(uint32_t param_types, TEE_Param params[4])
基本过程:
从参数获取obj_id的过程类似上面两个函数,这里不重复了;
首先用函数
TEE_OpenPersistentObject
打开待删除的文件,需要注意其第三个参数flags,当需要删除文件时,flags必须包含TEE_DATA_FLAG_ACCESS_WRITE_META
,详见GP的文档,最后一个TEE_ObjectHandle 类型的参数object代表被打开的文件;然后用
TEE_CloseAndDeletePersistentObject1
删除获取到的object即可;GP文档显示,
TEE_CloseAndDeletePersistentObject1
是为了替代已经过期了的TEE_CloseAndDeletePersistentObject
函数;1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
res = TEE_OpenPersistentObject(TEE_STORAGE_PRIVATE, obj_id, obj_id_sz, TEE_DATA_FLAG_ACCESS_READ | TEE_DATA_FLAG_ACCESS_WRITE_META, /* we must be allowed to delete it */ &object); if (res != TEE_SUCCESS) { EMSG("Failed to open persistent object, res=0x%08x", res); TEE_Free(obj_id); return res; } TEE_CloseAndDeletePersistentObject1(object); TEE_Free(obj_id); return res;
编译TA
不同平台的编译命令和生成路径各有不同,编译成功后,通常会生成一个 UUID.ta 文件 (UUID就是第2.1节生成的UUID的值);
集成TA到设备
将上述 UUID.ta 文件,放到项目的prebuilt资源目录;
修改设备的mk文件,集成UUID.ta到设备的
/vendor/lib/optee_armtz/UUID.ta
即可;
2.4 内存相关注意事项
上面已经零散提到了内存相关的限制或者规定,这里汇总一下:
CA就是普通的C代码,所以可以正常使用C的标准malloc和free函数;
TA的内存的申请和释放,必须用 TEE_Malloc 和 TEE_Free;
TA使用来自CA的memref类型的入参携带的内存,需要先TEE_Malloc申请一段TA内的内存,然后再用 TEE_MemMove将入参携带的内存信息拷贝到刚才申请到的地址,然后使用TEE_MemMove的目的地内存地址的数据;
如果期望从TA向CA返回bytes数据,则:
(1)CA端必须先用TEEC_AllocateSharedMemory方法申请
TEEC_SharedMemory
类型的一段内存,然后将内存地址给到CA调用 TEEC_InvokeCommand 时某个参数的memref.parent
,且配置这个参数类型是OUTPUT的;(2)TA端计算完毕后,为了安全起见,必须用TEE_CheckMemoryAccessRights 方法检查CA的参数给的缓冲区是否有可写;如果可写,则用TEE_MemMove 的方式将待返回数据复制到入参的
memref.parent
(即CA端申请到的TEEC_SharedMemory
内存里),然后CA端就能获取返回的内容了;【TEE_CheckMemoryAccessRights不是必须的,例如OPTEE的例子里面就没有检查,但还是推荐检查一下】
(3)CA端获取数据后,程序退出前,必须用 TEEC_ReleaseSharedMemory 的方式释放申请到的
TEEC_SharedMemory
内存;具体详见从TEE里面读取文件时CA和TA的代码;
3 可执行程序、JNI、Service
3.1 可执行程序
为了方便开发和调试,我们通常需要写一个命令行工具(实际上我们一般是先用命令行调试通过后,再编译成so集成的),集成在设备上的 /system/bin/mytee 位置;
通过这个命令行工具,可以实现:
- 往TEE内写入一行text文本;
- 往TEE内写入一个文件,指定好Android文件系统路径即可;
- 从TEE内读取文件,读取结果会以文件的形式保存到Android文件系统内;
- 从TEE内删除指定的文件;
使用方法:
在adb shell 执行下列命令:
mytee writetext <tee_file_id> <text>
mytee writefile <tee_file_id> <filepath>
mytee read <tee_file_id> <buffer_size>
mytee delete <tee_file_id>
相关代码在CA端的main函数里面,最终也是调用了真正执行读写功能的函数;
代码逻辑相对比较简单,唯一需要注意的是函数fread和fwrite返回值的含义,这些都是纯C代码,与TEE核心逻辑无关;
|
|
3.2 JNI
我们写一个名为 com.wang.tee.MyTeeClient
的类专门负责java层;
我们定义了三个native方法,以及static代码块来加载so库;
MyTeeClient.java 里:
|
|
对应地,在C层,即mytee_tee_ca.c 里面,需要添加注册函数的代码:
|
|
3.2.1 写文件
Java层:
为了方便,我们提供了写bytes数组、写文件、写字符串的功能,本质上都是调用了 native_write 写一个byte[] 到TEE;
有一点需要特别注意,所有对native方法的调用,都必须放到synchronized块里面。
原因是,我们发现对于同一个TA,如果CA端在尚未关闭所有Session之前就重新打开新的Session,会导致系统重启。文档里似乎并没有强调这一点,我猜测这是为了安全考虑;
更进一步说,我们也可以得到这样的结论:MyTeeClient这个类只能有一个实例,实际项目中,我们一般将其放到某个特定系统服务里。
|
|
C层:
|
|
3.2.1 删文件
删除文件的逻辑最简单,只涉及到一个参数;
同样,对native方法的调用也要做同步处理;
Java层:
|
|
C层:
|
|
3.2.3 读文件
Java层:
读取文件时,可能面临这样“既要又要”的诉求:如果读取不到,则我希望知道原因,如果读取成功了,我希望能知道实际读取的字节数;怎样让C层通过一个返回值告知我这一切呢?
解决方法也很简单,我们发现TEE执行失败的错误码都是负值,而文件大小都是正值。那我们就可以这样设计:执行失败,则返回错误码,执行成功则返回文件大小。我们根据返回值的正负就能知道执行成功与否,如果成功了,那文件大小也知道了;
这也可能就是为什么错误码一般都是负值的原因吧。既然正值有“读取了多少字节”之类的语义,那将错误码设计成负值就天然地将二者区分开来了。
|
|
C层:
|
|
关于JNI里数组处理地一点引申:
上面C层处理Java层数组时用到了函数GetByteArrayElements,关于这个话题,有必要多说几句。
在JNI中,下面三个函数都用来访问Java中的字节数组(byte[]
)的,但它们的使用方式和效率有所不同。
|
|
它们的区别有:
GetByteArrayRegion:
对java数组的处理是“只读”的,即每次都从Java层拷贝一份到Native层,所以Native层对拷贝所得的jbyteArray 做任何处理也不会影响到java层的数据;
因为存在拷贝,其效率也是最低的;
GetByteArrayElements:
返回一个指向Java字节数组的直接指针,允许在本地代码中访问和修改数组的内容;
如果指定参数isCopy为JNI_TRUE,则会创建数据的副本(此时对副本的修改不影响Java堆中的数据);如果指定参数isCopy为JNI_FALSE,则Native层会直接使用Java堆中的直接内存;
使用后需要调用 ReleaseByteArrayElements()
来释放本地指针并同步数据回Java数组;【特别是创建时参数isCopy为JNI_TRUE的情况,必须调用ReleaseByteArrayElements且确保最后一个参数mode必须是0】;
GetPrimitiveArrayCritical:
该函数提供对Java原始数组(如 byte[]
、int[]
等)元素的“临界访问”。它为本地代码提供对数组的高效访问,并且通过禁止Java垃圾回收器在访问期间移动该数组来避免性能损失(也就是说这个函数调用期间JVM是无法执行gc的,可能会影响JVM的性能)。
因为不存在数据拷贝且调用期间禁用了gc,所以这种访问方式是最高效的,但相对较危险,因为它会直接操控原始内存。
在使用后需要调用 ReleasePrimitiveArrayCritical()
来释放资源。
3.3 Service
作为一个完整的解决方案,系统需要向app提供API以实现对TEE的读写,一般项目中,我们都是将其放到一个自定义的系统级Service里;
怎样添加系统级Service是Android系统开发经常遇到的一个需求。网上写这个的太多了,就不再赘述其实现逻辑了。
这里简单提供一个图来描述这些关系:
这个图实际上描述了实际项目中云端校验设备端证书的过程,即证书在工厂生产阶段执行初始化,然后所有app都通过系统服务MyCompanyMS来读取证书并提交给云端校验设备合法性;
除了mytee这个调试用的可执行程序外,所有的对TEE的访问都是通过系统服务来完成的,所以实际项目中,mytee这样的可执行程序只能集成到userdebug版本里。一方面是为了安全性,另一方面mytee的存在实际上是破坏了所有调用Session都从系统服务中来的原则,mytee和系统服务并发时,可能导致设备重启等异常问题。
至此,一个完整的TEE模块我们就开发完毕了。
希望上述这些内容对大家有帮助。