4. SM2 签名算法在 TEE 中的实现

好,我们进入正题。SM2 签名在 TEE 里跑,跟普通 Linux 应用层签名最大的区别在哪?说白了,就是私钥的“不可见性”。在普通环境里,私钥要么存在文件里,要么存在内存里,总有办法把它 dump 出来。但在 TEE 里,私钥一旦生成,就永远待在安全世界里,REE 侧只能通过特定的接口去“请它签名”,但永远看不到私钥本身。

我刚开始做这个方案的时候,也犯过嘀咕:不就是调个签名接口吗,有什么难的?后来踩了几个坑才明白,TEE 里的签名流程,从数据流入到签名流出,每一步都有讲究。下面我把整个链路拆开来讲。

核心原则:REE 侧永远不接触 SM2 私钥明文。签名操作在 TEE 内部完成,REE 只传递待签名数据,并接收签名结果。

4.1 签名流程:从 REE 到 TEE 的完整调用

一个完整的 SM2 签名流程,大致分这么几步:

  1. REE 应用发起签名请求——应用层准备好待签名的数据(比如一段交易报文),通过 TEE Client API 发起调用。
  2. 数据传入 TEE——数据通过共享内存或安全通道传入 TEE 侧。这里要注意,如果数据本身是敏感信息,建议在 REE 侧先做一次加密,到 TEE 内部再解密。
  3. TEE 内部执行 SM2 签名——TEE 中的可信应用(TA)收到请求后,调用密码学库的 SM2 签名函数,使用存储在安全存储中的私钥进行签名。
  4. 签名结果返回 REE——签名结果(r, s)通过安全通道返回给 REE 应用。
  5. REE 应用使用签名结果——应用拿到签名后,可以将其附加到原始数据中,发送给验证方。

嗯,这里要注意一个细节:待签名数据通常需要先做一次哈希。SM2 签名算法本身是对消息的哈希值进行签名,而不是对原始消息直接签名。这个哈希过程可以在 REE 侧做,也可以在 TEE 侧做。我个人建议在 TEE 内部做哈希,原因很简单——防止 REE 侧篡改数据。

我的经验:曾经有个项目,REE 侧先算好哈希再传给 TEE 签名。结果发现 REE 侧被攻破后,攻击者可以替换哈希值,让 TEE 给恶意数据签名。后来我们改成 TEE 内部做哈希,彻底堵住了这个漏洞。

4.2 TEE 内部调用签名接口

在 TEE 内部,SM2 签名的实现通常依赖密码学库。以 OP-TEE 为例,它内部集成了 libtomcrypt 或 mbed TLS,我们可以直接调用封装好的接口。

下面是一个简化的 TA 端签名代码示例:

// TA 端 SM2 签名接口示例
TEE_Result sm2_sign(uint8_t *data, size_t data_len,
                    uint8_t *signature, size_t *sig_len) {
    // 1. 计算 SM3 哈希
    uint8_t hash[32];
    sm3_hash(data, data_len, hash);

    // 2. 从安全存储加载私钥
    sm2_private_key_t priv_key;
    if (load_private_key(&priv_key) != TEE_SUCCESS) {
        return TEE_ERROR_GENERIC;
    }

    // 3. 执行 SM2 签名
    if (sm2_sign_internal(&priv_key, hash, sizeof(hash),
                          signature, sig_len) != 0) {
        return TEE_ERROR_GENERIC;
    }

    return TEE_SUCCESS;
}

你看,代码逻辑并不复杂。但有几个关键点我得提醒你:

  • 私钥加载:私钥必须从 TEE 的安全存储中读取,不能从 REE 侧传入。安全存储通常由 TEE 内核提供,数据在写入时会加密,读取时自动解密。
  • 内存保护:私钥在 TA 内存中应该是临时存在的,签名完成后立即擦除。我习惯用 memset_s 或平台提供的安全清零函数,防止编译器优化掉。
  • 错误处理:任何一步失败,都不能返回部分签名结果。否则可能泄露私钥信息。
避坑指南:我曾经见过一个实现,签名失败后把私钥残留在了 TA 的堆内存里。后来通过内存 dump 分析,攻击者拿到了私钥片段。所以,用完即焚是 TEE 开发的基本素养。

4.3 签名验证的完整链路

签名验证通常发生在 REE 侧或远端服务器上。验证方需要知道签名者的公钥,以及原始消息。验证流程如下:

  1. 获取公钥:公钥可以从 TEE 中导出,但导出过程需要授权(比如用户确认或设备解锁)。公钥本身不敏感,但需要保证其真实性。
  2. 计算消息哈希:使用 SM3 对原始消息计算哈希值。
  3. 执行 SM2 验签:使用公钥、哈希值和签名值 (r, s) 进行验证。
  4. 输出验证结果:成功或失败。

这里有一个常见的误解:验签必须在 TEE 里做吗?不一定。验签只需要公钥,公钥是公开的,所以在 REE 侧验签是安全的。但如果你对 REE 侧的安全性不放心,或者验签逻辑本身需要保护(比如防止被绕过),也可以把验签放到 TEE 里做。

下面这张图展示了完整的签名与验证链路:

SM2 签名与验证完整链路 REE 侧(普通世界) 应用层 TEE Client API 共享内存 安全通道 数据传入 TEE 侧(安全世界) 可信应用 (TA) 密码学库 安全存储 私钥 签名结果 验证方(REE 侧或远端服务器) 获取公钥 → SM3 哈希 → SM2 验签 → 输出结果 签名结果 + 公钥

4.4 关键安全考量

讲完了流程,我再补充几个实际项目中容易踩的坑:

安全风险 描述 应对措施
重放攻击 攻击者截获签名结果,重复使用 在待签名数据中加入时间戳或随机数(nonce)
侧信道攻击 通过功耗、电磁辐射等物理信息窃取私钥 使用恒定时间算法,避免分支依赖于私钥值
私钥泄露 私钥在 TEE 内部被非法读取 严格限制 TA 的访问权限,使用安全存储加密
数据篡改 REE 侧在传入数据前篡改内容 在 TEE 内部计算哈希,或使用 MAC 保护数据完整性
我的习惯:每次签名前,我都会在 TA 里加一个日志,记录签名请求的源 TA 和数据的哈希值。虽然 TEE 里日志功能有限,但这对事后审计非常有帮助。

最后说一句,SM2 签名在 TEE 里的实现,本质上是一个信任链的问题。TEE 提供了硬件级别的隔离,但上层应用怎么用好这个隔离,才是真正的考验。你想想看,如果私钥保护得再好,但签名接口被随意调用,那跟裸奔有什么区别?所以,权限控制和调用频率限制也是方案设计里不可忽视的一环。