4、SM2数字签名:签名与验签流程、TEE内签名保护机制、防重放攻击设计
数字签名是密码学里最常用的功能之一。SM2 签名算法,说白了就是「用私钥签名,用公钥验签」。但在 TEE 里做签名,跟普通 App 里调个接口完全是两码事。我最早接触 TEE 签名时,以为只是把私钥放进去就行,结果踩了不少坑——嗯,今天我把这些经验都揉进这一章里。
4.1 SM2 签名与验签流程
先回顾一下标准流程。SM2 签名算法基于椭圆曲线,签名结果是两个大整数 (r, s)。验签时用公钥恢复出 r',跟签名里的 r 比对。
4.1.1 签名流程
- 预处理:计算 Z 值(用户标识 + 公钥的杂凑值),拼接消息 M 得到 M' = Z || M。
- 杂凑:对 M' 做 SM3 杂凑,得到 e。
- 生成随机数:产生随机数 k ∈ [1, n-1]。
- 计算椭圆曲线点:(x1, y1) = [k]G。
- 计算 r:r = (e + x1) mod n。若 r=0 或 r+k=n,回到步骤 3。
- 计算 s:s = ((1 + dA)^(-1) * (k - r * dA)) mod n。若 s=0,回到步骤 3。
- 输出签名:(r, s)。
4.1.2 验签流程
- 预处理:同样计算 Z 值,拼接消息 M 得到 M'。
- 杂凑:得到 e'。
- 检查 r, s:是否在 [1, n-1] 内。
- 计算 t:t = (r + s) mod n。若 t=0,验签失败。
- 计算椭圆曲线点:(x1', y1') = [s]G + [t]PA。
- 计算 R:R = (e' + x1') mod n。
- 比较:若 R == r,验签通过;否则失败。
关键点:签名中的随机数 k 必须每次不同且不可预测。我在项目中见过有人用固定 k 做签名,结果私钥被直接算出来——这是灾难级的错误。
4.2 TEE 内签名保护机制
在 TEE 里做签名,核心目标只有一个:私钥不出 TEE。所有签名运算都在安全世界完成,REE 只能拿到签名结果。
4.2.1 私钥隔离
- 硬件隔离:私钥存储在 TEE 内部的安全存储区,REE 无法直接访问。
- 密钥派生:我建议用设备根密钥派生签名密钥,这样即使 TEE 存储被物理攻击,根密钥也能保护派生密钥。
- 会话密钥:每次签名会话使用临时会话密钥加密传输数据,防止侧信道攻击。
4.2.2 签名流程在 TEE 内的实现
// 伪代码:TEE 内 SM2 签名
int tee_sm2_sign(uint8_t *msg, uint32_t msg_len, uint8_t *sig_out) {
// 1. 验证调用者身份(REE 侧 TA 的 UUID)
if (!verify_caller_identity()) return TEE_ERROR_ACCESS_DENIED;
// 2. 从安全存储加载私钥
sm2_private_key_t priv_key;
if (secure_storage_read("sm2_priv", &priv_key) != TEE_SUCCESS) {
return TEE_ERROR_STORAGE_FAILURE;
}
// 3. 生成真随机数(硬件 TRNG)
uint8_t rand_buf[32];
tee_trng_get_random(rand_buf, 32);
// 4. 执行 SM2 签名(全部在 TEE 内完成)
sm2_sign(msg, msg_len, &priv_key, rand_buf, sig_out);
// 5. 清除敏感数据
memset(&priv_key, 0, sizeof(priv_key));
memset(rand_buf, 0, sizeof(rand_buf));
return TEE_SUCCESS;
}
个人经验:签名完成后一定要立即清除私钥和随机数缓冲区。我曾经在调试时忘了清空栈上的私钥副本,结果 crash dump 里把私钥泄露了——从那以后我每次签名完都强制 memset。
4.2.3 防侧信道攻击
| 攻击类型 | 防护措施 | 说明 |
|---|---|---|
| 时序攻击 | 常数时间实现 | 所有分支执行时间相同,不依赖数据 |
| 功耗分析 | 随机掩码 | 每次运算加入随机掩码,掩盖功耗特征 |
| 故障注入 | 双重计算 + 校验 | 签名计算两次,结果一致才输出 |
| 缓存侧信道 | TEE 独占缓存行 | 签名过程不使用共享缓存 |
4.3 防重放攻击设计
签名本身不防重放。你想想看,攻击者截获一个合法签名 (r, s),然后不断重放给验签方,验签方每次都通过——这就是重放攻击。
4.3.1 常见防重放方案
- 时间戳:签名时嵌入当前时间戳,验签时检查时间差是否在允许范围内。
- 随机数挑战:验签方先发送一个随机数 nonce,签名方将 nonce 纳入签名消息。
- 序列号:维护单调递增的序列号,验签方记录已使用的序列号。
4.3.2 TEE 内的防重放设计
在 TEE 里,我推荐用「单调计数器 + 消息绑定」的组合方案。TEE 硬件通常提供单调计数器,只能递增不能回退。
// 伪代码:TEE 内防重放签名
int tee_anti_replay_sign(uint8_t *msg, uint32_t msg_len,
uint8_t *nonce, uint32_t nonce_len,
uint8_t *sig_out) {
// 1. 读取单调计数器当前值
uint32_t counter;
tee_monotonic_counter_read(&counter);
// 2. 构造签名消息:msg || nonce || counter
uint8_t *signed_data = malloc(msg_len + nonce_len + 4);
memcpy(signed_data, msg, msg_len);
memcpy(signed_data + msg_len, nonce, nonce_len);
memcpy(signed_data + msg_len + nonce_len, &counter, 4);
// 3. 执行签名
int ret = tee_sm2_sign(signed_data, msg_len + nonce_len + 4, sig_out);
// 4. 递增计数器(原子操作)
tee_monotonic_counter_increment();
free(signed_data);
return ret;
}
注意:单调计数器在 TEE 重启后不会重置。如果计数器溢出,需要设计轮换密钥机制。我曾经在一个项目中没考虑溢出,结果计数器归零后所有旧签名都变成了有效——修这个 bug 花了两周。
4.3.3 验签方的防重放检查
- 从签名消息中提取 nonce 和 counter。
- 检查 nonce 是否在有效期内(通常用时间戳判断)。
- 检查 counter 是否大于已记录的最大值。
- 更新已记录的最大 counter 值。
- 执行标准 SM2 验签。
4.4 整体流程框架
下面这张图把签名、验签、TEE 保护、防重放串在一起。你一看就明白。
4.5 避坑指南
我曾经踩过的坑:
- 随机数质量:TEE 的 TRNG 可能被禁用或降级。一定要检查 TRNG 状态,否则签名随机数可能可预测。
- 计数器持久化:单调计数器写入 flash 有寿命限制。我建议用磨损均衡算法,或者结合时间戳减少写入频率。
- 并发签名:多个 TA 同时请求签名时,计数器可能乱序。需要加锁或使用每个 TA 独立的计数器。
- 验签方状态同步:如果验签方是多实例部署,counter 最大值需要共享。可以用分布式锁或数据库记录。
我的建议:在 TEE 里做签名,永远假设 REE 是恶意的。所有输入都要校验,所有输出都要确认完整性。签名函数内部不要依赖任何来自 REE 的数据——包括消息长度、指针有效性等。