4、镜像签名与验证:镜像格式设计、签名工具链、验签流程

镜像签名与验证,说白了就是给固件上一把「数字锁」。你想想看,如果启动过程中任何一段代码都能被替换,那安全启动就是个摆设。我做过好几个量产项目,每次跟客户聊到这个环节,他们最关心的就是:签名到底签在哪儿?验签会不会拖慢启动速度?

嗯,今天咱们就把这三个问题拆开揉碎了讲清楚。

4.1 镜像格式设计——签名该放哪儿?

镜像格式不是随便定的。我见过不少团队,先把固件编译出来,再想办法往里面塞签名。结果呢?签名位置不固定,验签代码到处找,最后搞得一团糟。

我个人习惯的做法是:在设计镜像格式时,就把签名字段预留好。一个典型的签名镜像结构长这样:

+----------------------------+
| 镜像头 (Image Header)       |
| - Magic Number (4B)        |
| - 镜像版本 (4B)             |
| - 镜像长度 (4B)             |
| - 哈希算法标识 (1B)         |
| - 签名算法标识 (1B)         |
| - 保留字段 (2B)             |
+----------------------------+
| 镜像载荷 (Image Payload)    |
| - 实际固件数据              |
+----------------------------+
| 签名数据 (Signature Block)  |
| - 签名长度 (4B)             |
| - 签名值 (N Bytes)          |
| - 证书链 (可选)             |
+----------------------------+

核心原则:签名数据必须放在镜像末尾,且与载荷数据物理隔离。这样验签时可以先计算载荷的哈希,再验证签名,不需要把整个镜像加载到内存。

这里有个细节要注意:镜像头里的哈希算法标识和签名算法标识。我在一个项目里吃过亏——当时默认用 SHA256 + RSA2048,后来客户要求换成 ECDSA。因为没有预留算法标识字段,整个镜像格式都得改,折腾了两周。

所以我的建议是:哪怕你现在只用一种算法,也把标识字段留好。1 个字节就够了,0x01 表示 SHA256,0x02 表示 SHA384,以此类推。

4.2 签名工具链——怎么给镜像上锁?

签名工具链,就是一把「数字刻刀」。它把私钥和镜像揉在一起,生成签名数据。我常用的工具链分两种:命令行工具和集成脚本。

4.2.1 命令行签名工具

以 OpenSSL 为例,一个典型的签名命令长这样:

# 1. 计算镜像载荷的哈希
openssl dgst -sha256 -binary firmware.bin > firmware.hash

# 2. 用私钥对哈希签名
openssl pkeyutl -sign \
    -inkey private_key.pem \
    -keyform PEM \
    -in firmware.hash \
    -out firmware.sig

# 3. 将签名附加到镜像末尾
cat firmware.bin firmware.sig > firmware_signed.bin

但说实话,这种手动操作在量产时根本不可行。你想想看,每天几千块板子,每块都要签名,总不能让人一条一条敲命令吧?

4.2.2 集成签名脚本

我建议写一个自动化脚本,把签名流程封装起来。下面是一个 Python 示例:

import hashlib
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding, rsa

def sign_image(image_path, key_path, output_path):
    # 读取镜像
    with open(image_path, 'rb') as f:
        image_data = f.read()
    
    # 计算哈希
    digest = hashlib.sha256(image_data).digest()
    
    # 加载私钥
    with open(key_path, 'rb') as f:
        private_key = serialization.load_pem_private_key(
            f.read(),
            password=None
        )
    
    # 签名
    signature = private_key.sign(
        digest,
        padding.PKCS1v15(),
        hashes.SHA256()
    )
    
    # 构造签名块
    sig_block = len(signature).to_bytes(4, 'big') + signature
    
    # 写入输出文件
    with open(output_path, 'wb') as f:
        f.write(image_data)
        f.write(sig_block)
    
    print(f"签名完成: {output_path}")

小技巧:在签名脚本中加入版本号检查。我曾经遇到过一次事故——脚本升级后签名格式变了,但旧版 BootROM 不兼容,导致整批板子变砖。从那以后,我每次改签名脚本都会在输出文件里嵌入版本号,验签时先检查版本兼容性。

4.3 验签流程——BootROM 怎么开锁?

验签是安全启动的「守门员」。它必须在 BootROM 里执行,因为 BootROM 是芯片上电后第一个执行的代码,不可篡改。

一个标准的验签流程分四步:

  1. 定位签名块:从镜像末尾读取签名长度,找到签名数据起始位置。
  2. 计算载荷哈希:对镜像头 + 镜像载荷计算哈希值。
  3. 公钥解密签名:用芯片固化的公钥解密签名,得到原始哈希。
  4. 哈希比对:如果两个哈希一致,验签通过;否则启动失败。

下面这张图展示了完整的验签流程:

镜像签名验证流程 BootROM 启动 从镜像末尾读取签名块 计算镜像载荷 SHA256 用公钥解密签名值 哈希比对 ✅ 验签通过 → 启动 ❌ 验签失败 → 停机 关键说明 • 公钥固化在芯片 OTP 中 • 签名块包含签名长度和值 • 哈希比对在硬件加速器中完成

4.4 验签的工程实现要点

验签代码写起来不难,但坑不少。我踩过的几个坑,今天一并分享给你。

4.4.1 公钥存储

公钥必须存储在芯片的 OTP (One-Time Programmable) 区域或 eFuse 中。为什么?因为一旦烧录,任何人都无法修改。我在一个项目里见过把公钥放在 Flash 里的设计——结果攻击者直接替换公钥,用自己的私钥签名固件,安全启动形同虚设。

警告:公钥存储区域必须设置写保护。量产时烧录公钥后,立即锁定 OTP 的写权限。我曾经见过产线工人误操作,把公钥区域擦除了,导致整批芯片报废。

4.4.2 验签性能优化

验签涉及非对称解密,计算量不小。以 RSA2048 为例,一次验签大约需要 5-10 毫秒。对于启动时间要求苛刻的场景(比如汽车电子,要求 100 毫秒内完成启动),这个时间占比很高。

我常用的优化手段:

  • 硬件加速器:如果芯片有 RSA/ECC 硬件加速器,一定要用。性能可以提升 10 倍以上。
  • 哈希预计算:如果镜像不变,可以预计算哈希值并存储在镜像头中。验签时直接比对预计算哈希,跳过哈希计算步骤。
  • 分级验签:只对关键代码段(如内核)做完整验签,非关键驱动只做哈希校验。

4.4.3 错误处理

验签失败后怎么办?我见过两种处理方式:

策略 行为 适用场景
硬停机 CPU 进入死循环,不执行任何代码 安全等级最高的场景(如金融支付)
降级启动 进入恢复模式,等待合法镜像更新 消费电子(如路由器、智能家居)

我个人更倾向于降级启动。你想想看,如果设备因为一次升级失败就变砖,用户得多崩溃。但降级启动必须确保恢复模式本身是安全的——也就是说,恢复模式的代码也必须经过签名验证。

避坑指南:我曾经在一个项目里只做了硬停机,结果客户现场升级时网络中断,镜像只写了一半。验签失败,设备直接变砖。最后不得不派人去现场用 JTAG 恢复。从那以后,我所有项目都加了降级启动逻辑。

4.5 签名算法的选型建议

算法选型是个老生常谈的话题。我直接给结论:

  • RSA2048 + SHA256:最成熟,兼容性最好。适合对性能要求不高的场景。
  • ECDSA P-256:签名短(64 字节),验签快。适合资源受限的 MCU。
  • Ed25519:新一代算法,安全性高,签名和验签都很快。但部分老芯片不支持。

嗯,镜像签名与验证这块,核心就是「格式设计、工具链、验签流程」三个环节。每个环节都有细节,但只要你把公钥存好、签名格式定好、验签流程测好,安全启动的根基就稳了。