4. 启动镜像签名:SM2-DSA、SM3 哈希与签名数据结构

好,咱们进入第四章节。前面几章我们把安全启动的骨架搭起来了,也聊了 SM2 椭圆曲线的基本原理。这一章要落地了——怎么给启动镜像打上签名,怎么设计这个签名数据结构。

说白了,签名就是给镜像贴个「防伪标签」。启动时校验这个标签,就能知道镜像是不是原厂的、有没有被篡改过。我做过好几个安全启动方案,每次最头疼的不是算法本身,而是签名数据怎么组织、怎么和镜像绑定。

4.1 整体流程:先哈希,再签名

签名不是直接对整块镜像数据做 SM2 运算。你想想看,镜像可能几兆甚至几十兆,SM2 签名算法虽然安全,但处理大块数据效率并不高。正确的做法是:

  1. 用 SM3 对镜像计算哈希值,得到一个 256 位的摘要。
  2. 对这个摘要做 SM2 签名,生成签名值。
  3. 把签名值和镜像打包在一起,或者单独存放。

为什么先哈希?因为 SM3 是哈希算法,输入任意长度数据,输出固定 32 字节。签名只对这 32 字节操作,速度快,而且哈希的雪崩效应保证了哪怕镜像改了一个比特,签名校验就会失败。

核心原则:签名对象是镜像的哈希摘要,不是镜像本身。这是行业通行做法,也是安全启动的基石。

4.2 SM3 哈希计算:细节决定成败

SM3 算法输出 256 位(32 字节)的哈希值。计算过程本身不复杂,但有几个坑我得提醒你。

第一,填充规则。 SM3 对输入数据做填充,保证长度是 64 字节的倍数。填充方式是在数据末尾加一个 0x80,然后补 0,最后 8 字节是原始数据长度(按位计算)。这个规则和 SHA-256 类似,但细节不同,千万别混用。

第二,计算范围。 签名时到底对哪些数据算哈希?我见过有人只对代码段算,有人对整片 Flash 区域算。我个人习惯是对「镜像头 + 镜像体」一起算,但排除签名本身占用的区域。这样签名数据可以独立更新,不影响哈希结果。

第三,字节序。 SM3 算法内部使用大端序。如果你的平台是小端 CPU(比如 ARM Cortex-M 系列),需要在输入前做字节序转换,或者在输出后统一处理。我曾经在一个项目里忘了做这个转换,调试了整整两天才找到原因。

避坑指南: 我建议在哈希计算前后都打印一次摘要值,和 PC 端工具算出来的对比。如果一致,再往下走。这一步能省掉后面 90% 的调试时间。

4.3 SM2-DSA 签名:算法要点

SM2 数字签名算法(SM2-DSA)基于椭圆曲线。签名过程生成两个大整数 (r, s),每个 32 字节,总共 64 字节。具体步骤我不展开数学推导了,但有几个工程要点必须说清楚。

参数 长度 说明
r 32 字节 签名第一部分,由随机数 k 和基点 G 计算得出
s 32 字节 签名第二部分,由私钥、哈希值、r 共同计算
签名值总长 64 字节 r || s,按顺序拼接

随机数 k 的重要性。 SM2 签名需要一个随机数 k。这个 k 必须每次都不一样,而且不能泄露。如果两次签名用了同一个 k,私钥就能被算出来。我在项目中用过硬件真随机数发生器(TRNG),也用过软件伪随机数,但后者一定要有足够的熵源。

签名验证流程。 验证方用公钥、原始消息的哈希值、以及 (r, s) 做计算。如果验证通过,说明签名确实由对应的私钥生成,且消息未被篡改。验证失败的原因通常有三个:公钥不匹配、哈希值不一致、签名数据损坏。

警告: 不要在 BootROM 里实现完整的 SM2 验签算法。BootROM 空间有限,建议只实现验签流程,签名生成放在 PC 端或 HSM(硬件安全模块)上。BootROM 里代码越少,出 bug 的概率越低。

4.4 签名数据结构设计

签名数据怎么组织?这不是随便拼一下就能用的。我见过不少方案,签名和镜像分离存放,结果升级时签名丢了,整个设备变砖。好的数据结构应该包含以下信息:

// 签名数据结构示例
typedef struct {
    uint32_t    magic;          // 魔数,用于识别签名块,例如 0x5349474E ("SIGN")
    uint32_t    version;        // 结构版本号,方便后续扩展
    uint32_t    image_size;     // 镜像大小(字节)
    uint32_t    hash_algo;      // 哈希算法标识,0x01 = SM3
    uint8_t     hash[32];       // SM3 哈希值
    uint8_t     signature[64];  // SM2 签名值 (r || s)
    uint32_t    reserved[4];    // 保留字段,对齐到 128 字节
    uint32_t    crc32;          // 对整个签名结构的 CRC32 校验
} __attribute__((packed)) boot_signature_t;

这个结构体 128 字节,对齐友好。魔数用来快速识别签名块是否存在;版本号允许未来升级签名算法;CRC32 保护签名结构本身不被篡改。

存放位置。 我建议把签名结构放在镜像末尾,或者放在固定的偏移位置。比如:

  • 方案 A: 镜像起始地址 + 镜像大小 = 签名结构起始地址。适合固定大小的存储区域。
  • 方案 B: 签名结构放在单独的分区。适合多镜像场景,每个镜像对应一个签名。
  • 方案 C: 签名嵌入镜像头部。适合单镜像、小容量设备。

我个人偏爱方案 A。它简单,不需要额外的分区表,而且升级时签名和镜像一起写,不容易出现不一致。

4.5 整体流程示意图

下面这张图展示了从镜像到签名、再到启动校验的完整流程。我把它画成 SVG,方便你理解数据流向。

SM2 启动镜像签名与校验流程 启动镜像签名与校验流程 签名生成(PC / HSM 端) 原始启动镜像 SM3 哈希计算 32 字节哈希摘要 SM2 签名 (r, s) 镜像 + 签名结构打包 启动校验(BootROM 端) 加载镜像 + 签名 分离镜像与签名数据 SM3 哈希计算 SM2 验签 (公钥) 校验通过 → 启动 左侧在安全环境中生成签名,右侧在 BootROM 中完成校验,两者使用相同的 SM3 和 SM2 算法

4.6 工程实践中的几个要点

最后,我结合自己的项目经验,给你几个实操建议。

关于密钥管理。 签名私钥必须存储在安全环境中,比如 HSM、加密的配置文件,或者离线电脑。公钥可以放在 BootROM 中,但一定要做防篡改保护。我见过有人把公钥放在 Flash 的固定地址,结果攻击者改了公钥,用自己的私钥签名恶意镜像——这等于安全启动形同虚设。

关于签名速度。 SM2 验签比 SM3 哈希慢得多。在 BootROM 中,验签时间可能占整个启动时间的 30%~50%。如果对启动速度有要求,可以考虑用硬件加速模块,或者只对关键镜像做签名校验,非关键镜像只做哈希校验。

关于版本兼容。 签名数据结构里的 version 字段不是摆设。我建议从 v1 开始设计,预留扩展位。未来如果需要支持 SM2 + SM3 以外的算法组合,只需要增加新的 version 值,BootROM 根据 version 选择对应的验签流程。这样不用改硬件,只升级固件就能支持新算法。

小技巧: 开发阶段可以用固定的测试私钥,方便调试。但量产前一定要换成真正的安全私钥,并且把测试私钥从代码中彻底删除。我见过有人把测试私钥留在固件里一起发布了,后果嘛...你懂的。

嗯,这一章的内容就到这里。签名数据结构的设计直接决定了安全启动的灵活性和可维护性,花时间把它设计好,后面会省很多事。