4、镜像签名与验证:镜像格式设计、签名算法选择、哈希链验证流程、多级签名支持

好,咱们接着聊。上一节我们把密钥体系搭起来了,现在该聊聊怎么把这些密钥真正用起来——也就是给固件镜像签名,然后在启动时验证它。

说实话,镜像签名这块儿,我见过太多“看起来安全、实际一捅就破”的设计了。有的直接把签名区放在镜像末尾,连个校验都没有;有的用了RSA-1024,觉得够用了,结果没几年就被破解了。嗯,咱们今天就把这块儿彻底讲透。

4.1 镜像格式设计:头部、载荷、签名区

一个标准的签名镜像,我习惯把它分成三个部分:头部(Header)载荷(Payload)签名区(Signature)。说白了,就是“说明、数据、凭证”。

头部里放什么?我个人习惯至少包含这些字段:

  • 魔数(Magic Number):用来快速识别是不是合法的镜像文件。比如0xDEADBEEF,或者你们团队自己约定的一个值。
  • 版本号(Version):这个太重要了,防回滚全靠它。我建议用递增的整数,别用字符串,字符串比较容易出bug。
  • 镜像长度(Image Length):整个镜像的大小,包括头部和签名区。Bootloader拿到后先校验这个长度,防止截断攻击。
  • 哈希算法标识(Hash Algorithm ID):比如0x01代表SHA-256,0x02代表SHA-384。这样以后换算法也方便。
  • 签名算法标识(Signature Algorithm ID):类似,标明用的是RSA还是ECDSA。
  • 载荷哈希值(Payload Hash):对载荷部分算出来的哈希,签名区签的就是这个值。

载荷部分就是真正的固件数据,可能是压缩的,也可能是加密的。我建议载荷本身也加一个CRC校验,双重保险。

签名区就是签名算法输出的结果。如果是RSA-2048,那就是256字节;如果是ECDSA P-256,那就是64字节(R+S各32字节)。

这里有个细节:签名区到底签的是整个镜像还是只签哈希? 我建议只签哈希。为什么呢?因为RSA签名对数据长度有限制,而且签名整个镜像太慢了。你想想看,一个几兆的固件,每次启动都要做一次RSA签名验证,那启动时间就受不了。所以标准做法是:先对载荷算哈希,再对哈希做签名。

镜像结构示意(我常用的布局):

+------------------+
|  Magic (4字节)    |
|  Version (4字节)  |
|  Image Length (4) |
|  Hash Alg ID (1)  |
|  Sig Alg ID (1)   |
|  Reserved (2字节)  |
|  Payload Hash (32)|
+------------------+
|  Payload (N字节)   |
+------------------+
|  Signature (M字节) |
+------------------+

4.2 签名算法选择:RSA vs ECDSA

这个选择题,我当年也纠结过。咱们直接对比一下:

特性 RSA ECDSA
签名长度 较大(RSA-2048 = 256字节) 较小(P-256 = 64字节)
验证速度 较快(公钥指数小) 较慢(需要点乘运算)
签名速度 较慢(私钥指数大) 较快
密钥生成 较慢 较快
安全性 RSA-2048 ≈ 112位安全强度 P-256 ≈ 128位安全强度
硬件支持 非常广泛 逐渐普及

我个人怎么选?如果MCU有硬件加速器,我倾向于ECDSA P-256。签名短、安全性高,而且密钥生成快。但如果你用的芯片比较老,没有硬件加速,那RSA-2048更稳妥——验证速度快,而且库支持非常成熟。

我曾经在一个项目里选了ECDSA,结果芯片的硬件加速器只支持RSA,ECDSA全靠软件算,启动时间多了将近两秒。后来赶紧改方案,换成了RSA。所以啊,选算法之前,一定先看看芯片的手册,看看硬件支持什么。

我的建议: 如果条件允许,优先选ECDSA P-256。签名短意味着镜像小,传输快。而且NIST已经建议2030年后淘汰RSA-2048了,咱们得有点前瞻性。

4.3 哈希链验证流程

好了,镜像格式定了,算法选了,那验证流程怎么走?我画个流程图给你看:

Bootloader启动
    ↓
读取镜像头部
    ↓
校验魔数 → 失败则停止启动
    ↓
检查版本号 → 低于当前版本则回滚拒绝
    ↓
校验镜像长度 → 防止截断
    ↓
读取载荷哈希值(从头部)
    ↓
对载荷计算哈希 → 与头部哈希比对
    ↓
不一致则镜像损坏,停止
    ↓
用公钥验证签名(对载荷哈希签名)
    ↓
验证通过 → 跳转到载荷执行
    ↓
验证失败 → 进入恢复模式

这里有个关键点:哈希链验证。什么意思呢?就是每一步的验证结果都依赖于上一步。比如,如果魔数不对,那后面的版本检查、长度校验都没必要做了。这样设计的好处是:尽早失败,节省时间。

我见过一个设计,先做签名验证,再做版本检查。结果签名验证通过了,版本检查却失败了——白白浪费了签名验证的时间。所以顺序很重要:先做轻量级的检查(魔数、版本、长度),再做重量级的检查(哈希、签名)

注意: 版本检查一定要在签名验证之前吗?不一定。但如果你先做签名验证,那签名验证通过后,你还需要再检查版本。我建议把版本信息也放到签名范围内,这样版本号被篡改了也能被签名验证发现。但这样有个问题:版本检查失败时,你仍然需要做一次签名验证才能知道版本被篡改了。嗯,这是个权衡。

4.4 多级签名支持

最后聊聊多级签名。说白了,就是允许多个实体对同一个镜像签名。比如,开发团队签一次,QA团队再签一次,或者不同的产品线有不同的签名密钥。

多级签名怎么实现?我常用的方法有两种:

  • 串联式(Chain):每个签名区依次排列,验证时按顺序验证。任何一个签名验证失败,镜像就不合法。
  • 并联式(Parallel):多个签名区并列,验证时只要有一个通过就行。这种适合“多选一”的场景,比如不同的OEM用不同的密钥。

我个人更推荐串联式,因为安全强度更高。你想想看,如果并联式,只要攻破一个密钥就能启动镜像,那安全性就取决于最弱的那个密钥了。

多级签名的镜像结构大概长这样:

+------------------+
|  头部             |
+------------------+
|  载荷             |
+------------------+
|  签名区1 (开发)    |
|  签名区2 (QA)     |
|  签名区3 (发布)    |
+------------------+

每个签名区里,除了签名值,还要包含签名者的ID和签名算法标识。这样Bootloader就知道用哪个公钥来验证。

避坑指南: 我曾经在一个项目里支持了最多8级签名,结果Bootloader的代码复杂度翻了一倍,还出了个bug——签名区索引越界。后来我学乖了,多级签名最多支持3级就够了,再多就是过度设计了。

好了,镜像签名这块儿就聊到这儿。下一节咱们聊聊怎么把公钥安全地存到芯片里——也就是信任根的建立。这个要是搞砸了,前面所有的签名验证都是白搭。