4、签名与验签流程:镜像签名工具链、验签流程实现、公钥管理策略
签名与验签,是整个安全启动的基石。说白了,就是给固件镜像发一张「身份证」,Bootloader 在加载前先查一下这张证是不是真的。我见过不少项目,加密算法选得挺强,结果公钥管理一塌糊涂,最后还是被攻破。这一章,咱们就把签名工具链、验签流程和公钥管理这三个环节彻底讲透。
4.1 镜像签名工具链
签名不是在嵌入式设备上做的,而是在你的 PC 或 CI 服务器上。工具链的核心任务就是:用私钥对镜像摘要加密,生成签名。
4.1.1 典型签名流程
- 计算摘要:对原始镜像(如 .bin)做哈希运算,得到固定长度的摘要(如 SHA-256)。
- 签名生成:用私钥对摘要进行非对称加密(如 RSA-2048 或 ECDSA),输出签名数据。
- 打包:将原始镜像 + 签名 + 元数据(版本号、镜像类型等)合并为一个签名镜像。
核心原则:私钥必须离线保存,绝不能出现在 CI 脚本或代码仓库里。我曾经见过有人把私钥硬编码在 Makefile 里……嗯,那项目后来整个安全体系都重做了。
4.1.2 常用签名工具对比
| 工具 | 算法支持 | 适用场景 | 个人评价 |
|---|---|---|---|
| OpenSSL | RSA、ECDSA、Ed25519 | 通用、CI 集成 | 功能最全,但命令行参数复杂 |
| Python Cryptography | RSA、ECDSA | 自动化脚本 | 我习惯用它做原型验证 |
| mbedTLS 自编译工具 | RSA、ECDSA | 资源受限环境 | 适合与 Bootloader 共用代码库 |
| HSM 专用工具 | 厂商自定义 | 高安全等级量产 | 硬件保护私钥,成本较高 |
4.1.3 签名脚本示例(Python + Cryptography)
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding, rsa
from cryptography.hazmat.backends import default_backend
def sign_image(image_path, private_key_path, output_path):
# 1. 加载私钥
with open(private_key_path, "rb") as key_file:
private_key = serialization.load_pem_private_key(
key_file.read(),
password=None, # 生产环境建议加密
backend=default_backend()
)
# 2. 读取镜像并计算摘要
with open(image_path, "rb") as img_file:
image_data = img_file.read()
digest = hashes.Hash(hashes.SHA256(), backend=default_backend())
digest.update(image_data)
image_hash = digest.finalize()
# 3. 签名
signature = private_key.sign(
image_hash,
padding.PKCS1v15(),
hashes.SHA256()
)
# 4. 打包:镜像 + 签名长度 + 签名 + 元数据
with open(output_path, "wb") as out_file:
out_file.write(image_data)
out_file.write(len(signature).to_bytes(2, 'big'))
out_file.write(signature)
# 可附加版本号、哈希算法ID等
print(f"签名完成,输出文件:{output_path}")
避坑指南:签名时一定要包含版本号或防回滚标记。我曾经遇到过只签镜像内容,结果攻击者把旧版本镜像的签名直接搬过来用——这就是经典的「回滚攻击」。
4.2 验签流程实现
验签发生在 Bootloader 里。设备上电后,Bootloader 在加载固件前,必须完成验签。这一步如果没过,系统直接拒绝启动。
4.2.1 验签核心步骤
- 提取签名:从签名镜像中分离出原始镜像和签名数据。
- 计算摘要:对原始镜像做同样的哈希运算。
- 公钥解密:用预置的公钥解密签名,得到解密后的摘要。
- 比对:对比步骤2和步骤3的摘要,一致则通过。
注意:验签必须在 Bootloader 的早期阶段完成,且验签代码本身不能被篡改。如果 Bootloader 的第一阶段(ROM code)没有验签能力,那整个安全链就断了。
4.2.2 Bootloader 验签代码片段(C 语言风格)
#include "crypto_wrapper.h" // 封装了 mbedTLS 或内部加密库
int verify_image(const uint8_t *image, uint32_t image_len,
const uint8_t *signature, uint32_t sig_len,
const uint8_t *public_key, uint32_t key_len) {
uint8_t computed_hash[32];
uint8_t decrypted_hash[32];
// 1. 计算镜像哈希
sha256_compute(image, image_len, computed_hash);
// 2. 用公钥解密签名
if (rsa_public_decrypt(signature, sig_len,
public_key, key_len,
decrypted_hash) != 0) {
return -1; // 解密失败
}
// 3. 比对哈希
if (memcmp(computed_hash, decrypted_hash, 32) != 0) {
return -2; // 验签失败
}
return 0; // 验签通过
}
你想想看,这段代码看起来简单,但实际落地时坑很多。比如哈希比对要用恒定时间比较,防止时序攻击。我早期一个项目就忽略了这点,结果被侧信道攻击抓到了漏洞。
4.2.3 验签失败处理策略
- 直接停机:进入死循环或触发硬件复位,不给攻击者任何反馈。
- 进入恢复模式:如果设备支持,可以进入一个最小化的恢复 Bootloader,等待合法镜像通过安全通道刷入。
- 记录错误日志:在安全存储区记录验签失败次数,用于检测暴力攻击。
我的建议:量产设备上,验签失败后不要打印任何调试信息。攻击者最喜欢从「Error: signature mismatch」这种信息里推断你的密钥结构。
4.3 公钥管理策略
公钥虽然可以公开,但它的完整性和可信根必须被保护。公钥一旦被篡改,攻击者就能用自己的私钥签名恶意镜像。
4.3.1 公钥存储位置
| 存储方式 | 安全性 | 灵活性 | 典型场景 |
|---|---|---|---|
| ROM 硬编码 | 最高(不可更改) | 最低 | 一次性量产,永不更新 |
| OTP(一次性可编程) | 高(熔断后不可改) | 低 | 量产时烧录,支持少量批次 |
| 安全存储区(如 TrustZone) | 中高 | 中 | 支持 OTA 公钥更新 |
| 外部 Flash(加密存储) | 中 | 高 | 需要密钥派生或解密后才能使用 |
4.3.2 公钥轮换与多级信任链
单把公钥用到底,风险太大。一旦私钥泄露,所有设备都得召回。我建议采用多级证书链:
- 根公钥:烧录在 ROM 或 OTP 中,永远不变。它只用来验证次级公钥的签名。
- 次级公钥:存储在可更新区域,由根私钥签名。可以定期轮换。
- 镜像签名公钥:由次级私钥签名,用于日常镜像验签。
实际经验:我在一个车规级项目里,根公钥烧在 OTP 里,次级公钥每季度轮换一次。即使次级私钥泄露,也只影响当季的镜像,不会波及整个产品生命周期。
4.3.3 公钥分发与更新流程
- 出厂烧录:根公钥在芯片出厂时写入 OTP。
- 首次启动:Bootloader 用根公钥验证次级公钥的签名,加载次级公钥到安全 RAM。
- OTA 更新公钥:新公钥必须由上一级私钥签名,通过安全通道下发。Bootloader 验签通过后,擦除旧公钥,写入新公钥。
关键约束:公钥更新操作必须是原子性的。如果更新过程中掉电,设备可能变成「砖头」。我习惯的做法是:先写入新公钥到备份区域,验签通过后再切换主区域指针。
4.4 签名与验签整体流程(SVG 框架图)
4.5 常见问题与避坑
- 签名算法选择:RSA-2048 目前够用,但新项目我建议直接上 ECDSA(如 P-256),密钥更短,验签更快。
- 哈希碰撞:别用 MD5 或 SHA-1。我在 2018 年还见过用 MD5 签名的 IoT 设备……那基本等于没锁门。
- 公钥硬编码风险:如果公钥写在代码里,攻击者可以通过反编译提取。一定要放在 OTP 或安全存储区。
- 验签性能:RSA-2048 验签在低端 MCU 上可能耗时几百毫秒。如果启动时间敏感,考虑 ECDSA 或硬件加速。
总结一句话:签名是给镜像上锁,验签是开锁,公钥管理是决定锁芯能不能被换掉。三个环节缺一不可,任何一个出问题,安全启动就形同虚设。