4. 签名验证实战:从密钥生成到Bootloader集成

签名验证,说白了就是给固件贴上一张「防伪标签」。MCU启动时先撕开标签验一下,是真的才让跑。这个流程在车规、工控、IoT设备里几乎是标配了。今天我就带你完整走一遍——从OpenSSL生成密钥对,到把验签代码塞进Bootloader。

4.1 整体流程概览

先看一张图,把整个链路串起来。你心里有个谱,后面动手才不会乱。

签名验证完整流程 签名验证完整流程 1. 生成密钥对 私钥(签名) + 公钥(验签) 2. 签名固件 SHA256 + RSA/ECDSA 3. 打包固件 固件 + 签名 + 元数据 4. 下载到设备 UART / SPI / OTA 5. Bootloader验签 公钥验签 → 通过/失败 6. 启动应用 跳转到应用入口 ⚠ 关键点: • 私钥必须离线保管,公钥烧录在Bootloader中 • 签名算法推荐ECDSA(密钥短、速度快)

4.2 生成密钥对:私钥锁保险柜,公钥贴门上

密钥对分两种角色:私钥用来签名,公钥用来验签。私钥一旦泄露,整个安全体系就崩了。我习惯把私钥放在离线机器上,甚至用U盘物理隔离。

4.2.1 生成RSA密钥对

RSA是目前最通用的方案。MCU端验签时,公钥长度建议用2048位,太长MCU扛不住,太短不安全。

# 生成RSA私钥(2048位)
openssl genrsa -out private.pem 2048

# 从私钥提取公钥
openssl rsa -in private.pem -pubout -out public.pem

# 查看公钥内容(PEM格式)
cat public.pem
我的习惯:生成私钥时加 -aes256 参数加密存储。虽然每次签名要输密码,但安全等级高一个档次。

4.2.2 生成ECDSA密钥对(推荐)

ECDSA用更短的密钥达到同等安全强度。256位ECDSA ≈ 3072位RSA。在资源受限的MCU上,我强烈推荐ECDSA。

# 生成ECDSA私钥(prime256v1曲线)
openssl ecparam -genkey -name prime256v1 -out ecdsa_private.pem

# 提取公钥
openssl ec -in ecdsa_private.pem -pubout -out ecdsa_public.pem

# 查看公钥
openssl ec -in ecdsa_public.pem -pubin -text -noout
注意:ECDSA的随机数质量直接影响安全性。如果随机数生成器有缺陷,私钥可能被还原。MCU端验签时,确保硬件TRNG可用。

4.3 对固件进行签名:给固件盖个章

签名过程分两步:先对固件做哈希摘要,再用私钥加密这个摘要。我见过有人直接对固件签名,效率低且不安全——正确的做法是先哈希。

4.3.1 计算固件哈希

# 计算固件文件的SHA256哈希
openssl dgst -sha256 -binary firmware.bin > firmware.hash

# 查看哈希值(十六进制)
xxd firmware.hash

4.3.2 用私钥签名哈希

# RSA签名
openssl pkeyutl -sign -in firmware.hash -inkey private.pem -out firmware.sig

# ECDSA签名(推荐)
openssl pkeyutl -sign -in firmware.hash -inkey ecdsa_private.pem -out firmware.sig

# 查看签名长度
ls -l firmware.sig
关键点:签名文件长度取决于算法。RSA-2048签名固定256字节,ECDSA-P256签名固定64字节(r+s各32字节)。这个长度在Bootloader中要预留好。

4.3.3 打包固件

把固件、签名、元数据(版本号、芯片ID等)打包成一个文件。我习惯用自定义头部格式:

# 打包命令示例
# 头部结构:魔数(4B) + 版本(4B) + 长度(4B) + 签名(64B/256B) + 固件数据
# 这里用cat简单演示
cat firmware.bin firmware.sig > firmware_signed.bin
字段 大小 说明
魔数 4字节 0xDEADBEEF,用于校验头部完整性
固件版本 4字节 防止回滚攻击
固件长度 4字节 实际固件数据长度
签名数据 64/256字节 ECDSA或RSA签名
固件数据 可变 实际固件二进制

4.4 Bootloader集成验签代码

这是最硬核的部分。Bootloader启动时,先读固件头部,提取签名,计算哈希,再用公钥验签。通过才跳转,不通过就原地等待或进入恢复模式。

4.4.1 公钥嵌入Bootloader

公钥不能从外部加载,否则攻击者可以替换。必须硬编码在Bootloader的只读区域。我一般放在链接脚本的.rodata段。

// 将公钥转换为C数组
// 使用openssl导出公钥的原始字节
openssl ec -in ecdsa_public.pem -pubin -outform DER | xxd -i

// 生成的C数组示例
const uint8_t public_key[] = {
    0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86,
    0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a,
    // ... 省略
};

4.4.2 验签核心函数

这里以mbedTLS为例,它是MCU上最常用的加密库。代码量不大,但每一步都不能错。

#include <mbedtls/pk.h>
#include <mbedtls/sha256.h>
#include <mbedtls/rsa.h>
#include <mbedtls/ecdsa.h>

/**
 * @brief 验签函数
 * @param firmware 固件数据指针
 * @param fw_len 固件长度
 * @param signature 签名数据指针
 * @param sig_len 签名长度
 * @return 0成功,-1失败
 */
int verify_firmware(const uint8_t *firmware, uint32_t fw_len,
                    const uint8_t *signature, uint32_t sig_len)
{
    int ret;
    mbedtls_pk_context pk;
    mbedtls_sha256_context sha256_ctx;
    uint8_t hash[32];

    // 1. 初始化公钥上下文
    mbedtls_pk_init(&pk);
    ret = mbedtls_pk_parse_public_key(&pk, public_key, sizeof(public_key));
    if (ret != 0) {
        // 公钥解析失败,直接返回
        return -1;
    }

    // 2. 计算固件SHA256哈希
    mbedtls_sha256_init(&sha256_ctx);
    mbedtls_sha256_starts(&sha256_ctx, 0); // 0表示SHA256
    mbedtls_sha256_update(&sha256_ctx, firmware, fw_len);
    mbedtls_sha256_finish(&sha256_ctx, hash);
    mbedtls_sha256_free(&sha256_ctx);

    // 3. 验签
    ret = mbedtls_pk_verify(&pk, MBEDTLS_MD_SHA256,
                            hash, 0, signature, sig_len);

    mbedtls_pk_free(&pk);

    return (ret == 0) ? 0 : -1;
}
我曾经踩过的坑:验签时忘记检查签名长度。攻击者传一个空签名,mbedtls内部可能直接返回成功。一定要在调用前校验sig_len是否等于预期值(ECDSA-P256为64,RSA-2048为256)。

4.4.3 Bootloader主流程集成

void bootloader_main(void)
{
    // 假设固件存储在外部Flash的0x08020000地址
    #define APP_ADDR    0x08020000
    #define SIG_OFFSET  (APP_ADDR + 12)  // 跳过头部12字节
    #define FW_OFFSET   (APP_ADDR + 12 + 64) // ECDSA签名64字节

    // 读取头部信息
    uint32_t magic = *(volatile uint32_t *)APP_ADDR;
    uint32_t fw_len = *(volatile uint32_t *)(APP_ADDR + 8);

    // 检查魔数
    if (magic != 0xDEADBEEF) {
        // 固件无效,进入恢复模式
        enter_recovery_mode();
        return;
    }

    // 验签
    int result = verify_firmware(
        (const uint8_t *)FW_OFFSET,
        fw_len,
        (const uint8_t *)SIG_OFFSET,
        64  // ECDSA-P256签名长度
    );

    if (result == 0) {
        // 验签通过,跳转到应用
        jump_to_app(APP_ADDR);
    } else {
        // 验签失败,进入恢复模式
        enter_recovery_mode();
    }
}

4.5 避坑指南

这部分是我这些年攒下来的血泪教训,你直接拿去用。

  • 公钥保护:公钥虽然叫「公」,但不能被篡改。Bootloader的只读区域要加写保护,防止通过调试接口修改。
  • 哈希碰撞:SHA256目前安全,但别用MD5或SHA1。我在一个老项目里见过用CRC32做签名的——那基本等于没锁门。
  • 回滚攻击:固件版本号要递增,Bootloader检查新固件版本必须大于当前版本。否则攻击者可以刷回有漏洞的老版本。
  • 时序攻击:验签函数不要返回详细的错误码(比如「签名格式错误」「哈希不匹配」),攻击者可以利用这些信息。统一返回「验签失败」即可。
  • 内存清零:验签完成后,把临时缓冲区(哈希、签名副本)清零。防止侧信道攻击通过内存残留数据推断密钥信息。
一句话总结:签名验证不是加个函数就完事了。密钥管理、防回滚、抗侧信道,每个环节都要闭环。我见过太多「验签通过了但安全还是被攻破」的案例,问题往往出在细节上。

好了,这一章的内容就到这里。你可以在开发板上先跑通RSA验签,再切换到ECDSA对比一下性能差异。动手试一次,比看十遍文档都管用。