第四章 签名验证机制:非对称加密在启动过程中的应用

上一章我们聊了哈希校验,它能保证代码没被篡改。但有个问题——哈希值本身谁来保护?攻击者完全可以连哈希一起改掉。这时候,非对称加密就该登场了。

说白了,签名验证就是给每一段启动代码盖一个「防伪章」。这个章只有持有私钥的人能盖,但所有人都能用公钥来验。我在做嵌入式安全方案时,经常跟团队讲一句话:「私钥是命根子,公钥是通行证。」

4.1 非对称加密基础回顾

非对称加密的核心,是「一对钥匙」:

  • 私钥:自己保管,绝不外传。用于签名或解密。
  • 公钥:公开分发。用于验签或加密。

你想想看,如果我用私钥对一段数据签名,任何人都能用我的公钥验证这个签名是不是我本人签的。这不就是数字世界的「手写签名」吗?

关键点:签名 ≠ 加密。签名证明「谁写的」和「没被改过」,加密保护「别人看不到」。启动信任链里,我们只关心签名验证,不涉及加密。

4.2 启动过程中的签名验证流程

在典型的信任链里,每一级启动代码在加载下一级之前,都要做三件事:

  1. 读取签名:从固件镜像的固定偏移位置,取出预先计算好的签名值。
  2. 计算哈希:对下一级代码的二进制内容做哈希运算(通常是 SHA-256 或 SHA-512)。
  3. 验签:用公钥解密签名,得到「原始哈希」,然后跟步骤2算出来的哈希比对。

如果一致,说明代码是合法的、没被篡改的。否则,直接停机或进入恢复模式。

我曾经在一个 IoT 项目里遇到过这种情况:某厂商的公钥居然硬编码在代码里,而且还能被攻击者通过漏洞改写。结果就是,攻击者用自己的私钥签了个恶意固件,设备照样启动。嗯,这属于典型的「信任锚点没锁死」。

4.3 常见的签名算法

算法 密钥长度 签名长度 典型应用场景
RSA-PKCS#1 v1.5 2048 / 4096 bit 256 / 512 字节 传统 UEFI Secure Boot
RSA-PSS 2048 / 4096 bit 256 / 512 字节 现代固件签名(更安全)
ECDSA (P-256) 256 bit 64 字节 ARM TrustZone / 嵌入式启动
Ed25519 256 bit 64 字节 新兴轻量级方案

我个人习惯在嵌入式场景里优先选 ECDSA 或 Ed25519。为什么?密钥短、签名短、计算快。RSA 4096 虽然安全,但在低端 MCU 上验签一次可能要几百毫秒,用户开机等得心焦。

4.4 公钥的存储与保护

公钥虽然可以公开,但它的「完整性」必须被保护。否则攻击者替换了公钥,你的签名验证就形同虚设。

常见的做法是:

  • 熔丝(eFuse):一次性写入,物理上不可改写。适合存储根公钥的哈希。
  • ROM 固化:公钥直接烧死在芯片的只读存储器里。BootROM 里通常就放一个根公钥。
  • 证书链:根公钥保护次级公钥,次级公钥再保护下一级。每一级都验上一级的签名。

避坑指南:我曾经见过一个方案,把公钥放在 Flash 里,还允许 OTA 更新。结果攻击者伪造了一个固件更新包,顺带把公钥也换了。从那以后,我坚持「根公钥必须硬件保护,不可更新」。

4.5 信任链中的签名传递

整个启动过程,就是一条「签名验证链」:

  1. BootROM:芯片出厂时固化的代码。它里面存着根公钥(或根公钥的哈希)。
  2. BootROM 验签 SPL/BL1:BootROM 从 Flash 加载第一级引导程序,验签通过后才执行。
  3. SPL 验签 BL2:SPL 加载并验签第二级引导程序。
  4. BL2 验签 U-Boot/EDK2:继续向上传递信任。
  5. Bootloader 验签 OS Kernel:最后,引导程序验签操作系统内核。

每一级都只信任上一级签过名的代码。只要根信任锚点(Root of Trust)是安全的,整条链就安全。

小提示:实际项目中,BootROM 通常只验签第一级引导程序的签名,后面的签名验证由软件完成。但软件验签用的公钥,必须由上一级签名保护。这就是「信任传递」的本质。

4.6 签名验证的代码示例

下面是一个简化的 ECDSA 验签流程,用伪代码展示核心逻辑:

// 假设公钥已经安全存储在 BootROM 中
uint8_t public_key[64];  // P-256 公钥 (x, y)

// 从 Flash 加载下一级代码和签名
uint8_t *next_stage = load_from_flash(ADDR_NEXT_STAGE);
uint32_t code_len = get_code_length(ADDR_NEXT_STAGE);
uint8_t *signature = load_from_flash(ADDR_SIGNATURE);  // 64 字节

// 1. 计算代码的哈希
uint8_t hash[32];
sha256(next_stage, code_len, hash);

// 2. 验签
int result = ecdsa_verify(
    public_key,      // 公钥
    hash,            // 代码哈希
    signature        // 签名
);

// 3. 判断结果
if (result == 0) {
    // 验签通过,跳转到下一级代码
    jump_to(next_stage);
} else {
    // 验签失败,进入安全恢复模式
    enter_recovery_mode();
}

实际工程里,你还要考虑防回滚(Rollback Protection)、签名格式解析、异常处理等。但核心骨架就是这三步:哈希 → 验签 → 跳转。

4.7 签名验证的常见攻击与防御

攻击方式 原理 防御手段
公钥替换 攻击者改写存储公钥的区域 公钥熔丝固化 / ROM 保护
签名伪造 攻击者用自己的私钥签名恶意代码 私钥严格管控 + 硬件安全模块
哈希碰撞 找到两个不同代码但哈希相同 使用 SHA-256 或更强的哈希算法
重放攻击 用旧版本的合法固件替换新版本 版本号检查 + 防回滚计数器
侧信道攻击 通过功耗/电磁泄露提取私钥 恒定时间实现 + 掩码技术

我记得有一次做安全审计,发现某款设备的 BootROM 验签时居然没有检查签名长度。攻击者传一个超长签名,导致缓冲区溢出,直接跳过了验签逻辑。嗯,这种低级错误在真实产品里并不少见。

4.8 信任链的 SVG 流程图

下面这张图展示了从 BootROM 到 OS Kernel 的完整签名验证流程:

启动信任链签名验证流程 启动信任链签名验证流程 BootROM(根信任) 验签 SPL / BL1 验签 BL2 验签 Bootloader 验签 OS Kernel 信任链核心原则 每一级只信任上一级 签过名的代码 根信任锚点不可篡改 验签失败处理 立即停止启动 进入安全恢复模式 记录错误日志

4.9 工程实践建议

最后,分享几个我在实际项目中踩过的坑和总结的经验:

  • 私钥管理是头等大事:签名私钥一旦泄露,整条信任链就废了。建议用 HSM(硬件安全模块)存储私钥,签名操作在 HSM 内部完成,私钥永远不离开硬件。
  • 验签性能要提前评估:有些低端 MCU 做一次 RSA 4096 验签可能要 1 秒以上。如果用户按电源键后要等 3 秒才能看到 Logo,体验会很差。我建议在芯片选型阶段就把验签时间纳入考量。
  • 防回滚必须配套:签名验证只能保证代码是合法的,但不能保证是最新版本。攻击者可以用旧版本固件(有已知漏洞)来降级攻击。所以一定要配合版本号检查和防回滚计数器。
  • 测试要覆盖异常路径:不要只测「验签通过」的 happy path。要测签名损坏、公钥错误、哈希不匹配、签名格式异常等各种情况。我曾经在测试中发现,某个平台的验签函数在传入空指针时会直接崩溃,而不是返回错误码。

一句话总结:签名验证是信任链的「守门员」。门守好了,后面的代码才能放心执行。门没守好,整个系统就是纸糊的。