4. 固件签名流程详解:签名前的准备(哈希计算)、签名生成(私钥签名)、签名验证(公钥验证)、完整签名流程拆解

固件签名这件事,说白了就是给固件贴上一张「防伪封条」。这张封条有两个作用:一是证明固件确实来自你(身份认证),二是证明固件没有被篡改过(完整性校验)。

我刚开始做嵌入式安全时,总觉得签名就是个数学魔术。后来踩过几次坑才明白,整个流程其实就三步:哈希 → 签名 → 验证。每一步都有讲究,咱们一个一个拆开看。

核心要点:签名不是加密,而是「哈希 + 非对称密钥」的组合拳。哈希保证内容完整性,私钥签名保证身份真实性。

4.1 签名前的准备:哈希计算

为什么签名前要先算哈希?你想想看,固件动辄几兆甚至几十兆,直接用私钥对整个固件做签名运算,性能扛不住。所以我们的做法是:先对固件算一个固定长度的哈希值,再对这个哈希值签名。

哈希函数的选择很关键。我个人习惯用 SHA-256,原因很简单:它足够安全,而且几乎所有硬件平台都支持硬件加速。SHA-1 已经被证明有碰撞风险,MD5 就更不用说了——我在一个老项目里见过还在用 MD5 的,当时就建议客户赶紧升级。

// 伪代码:计算固件哈希
#include <openssl/sha.h>

uint8_t firmware_buffer[FIRMWARE_SIZE];
uint8_t hash_output[SHA256_DIGEST_LENGTH];

SHA256_CTX ctx;
SHA256_Init(&ctx);
SHA256_Update(&ctx, firmware_buffer, FIRMWARE_SIZE);
SHA256_Final(hash_output, &ctx);

// 此时 hash_output 就是 32 字节的固件指纹

我的经验:哈希计算时,建议把固件的元数据(版本号、芯片ID、时间戳)也一起算进去。这样签名就绑定了特定版本和特定设备,防止固件被挪用到其他设备上。

4.2 签名生成:私钥签名

拿到哈希值之后,就该私钥上场了。私钥签名本质上就是用你的私钥对哈希值做一次「加密」——注意这里的引号,严格来说不是加密,而是签名算法。

常用的签名算法有两种:RSA 和 ECDSA。我做过对比,给你列个表:

特性 RSA (2048位) ECDSA (P-256)
签名长度 256 字节 64 字节
签名速度 较慢 较快
验证速度 较快 较慢
密钥生成 慢 快
硬件支持 广泛 逐渐普及

我个人更倾向于 ECDSA,尤其是资源受限的嵌入式设备。签名短、生成快,虽然验证稍慢,但固件升级场景下验证次数远少于签名次数。

// 伪代码:使用 ECDSA 签名
EVP_PKEY *pkey = load_private_key("private_key.pem");
EVP_MD_CTX *mdctx = EVP_MD_CTX_new();

EVP_DigestSignInit(mdctx, NULL, EVP_sha256(), NULL, pkey);
EVP_DigestSignUpdate(mdctx, hash_output, SHA256_DIGEST_LENGTH);

size_t sig_len;
EVP_DigestSignFinal(mdctx, NULL, &sig_len); // 获取签名长度
uint8_t *signature = malloc(sig_len);
EVP_DigestSignFinal(mdctx, signature, &sig_len);

// signature 就是最终的签名数据

注意:私钥必须妥善保管!我曾经见过一个团队把私钥直接放在 Git 仓库里,结果被外部人员拿到,整个产品线的固件都被伪造了。私钥应该存储在 HSM(硬件安全模块)或安全芯片中,至少也要加密存储。

4.3 签名验证:公钥验证

设备端拿到固件和签名后,怎么验证?很简单:用公钥解密签名,得到哈希值,再和固件自己算出来的哈希值对比。如果一致,说明固件没被改过,而且确实是私钥持有者签发的。

这里有个细节:公钥怎么安全地放到设备里?我建议在产线烧录时就把公钥写进一次性可编程(OTP)区域,或者用证书链的方式分发。千万别把公钥写在固件里——那等于没锁门。

// 伪代码:验证签名
EVP_PKEY *pubkey = load_public_key_from_otp();
EVP_MD_CTX *mdctx = EVP_MD_CTX_new();

EVP_DigestVerifyInit(mdctx, NULL, EVP_sha256(), NULL, pubkey);
EVP_DigestVerifyUpdate(mdctx, hash_output, SHA256_DIGEST_LENGTH);

int ret = EVP_DigestVerifyFinal(mdctx, signature, sig_len);
if (ret == 1) {
    // 验证通过,固件可信
} else {
    // 验证失败,拒绝启动
}

4.4 完整签名流程拆解

好了,把上面三步串起来,就是完整的固件签名流程。我画了一张图,帮你理清整个链条:

固件签名完整流程 签名端(开发/构建服务器) 固件二进制文件 SHA-256 哈希计算 32 字节哈希值 ECDSA 私钥签名 (私钥来自 HSM/安全存储) 签名数据(64 字节) 打包 验证端(目标设备) 接收固件 + 签名数据 SHA-256 哈希计算 ECDSA 公钥验证 (公钥来自 OTP/证书链) 哈希值比对 一致 → 启动固件 | 不一致 → 拒绝 固件可信,允许启动 关键点:签名端和验证端使用相同的哈希算法,验证端只持有公钥,私钥永不离开签名端

整个流程走下来,你会发现一个关键原则:私钥永不离开签名端。设备端只存公钥,公钥泄露了也没关系——它只能用来验证,不能用来签名。

我的建议:在构建服务器上做签名时,一定要把签名步骤做成独立的流水线阶段。我见过有人把私钥路径写死在构建脚本里,结果一次误操作把私钥打印到了日志中。正确的做法是用环境变量或密钥管理服务来传递私钥路径。

4.5 避坑指南

做固件签名联调时,有几个坑我反复踩过,今天一并告诉你:

  • 哈希算法不一致:签名端用 SHA-256,验证端用 SHA-1,结果验证永远失败。检查算法 ID 是否匹配。
  • 签名格式问题:ECDSA 签名有 DER 编码和原始编码两种格式。我建议统一用 DER 编码,兼容性更好。
  • 字节序问题:有些 MCU 是大端,有些是小端。签名数据在传输时一定要约定好字节序,否则验证端解出来是乱码。
  • 公钥存储位置:别把公钥放在可擦写的 Flash 区域。万一攻击者改了公钥,你的签名体系就形同虚设了。

曾经有一次:我在一个项目里发现设备验证签名总是失败,查了两天才发现是签名时把固件版本号也算进了哈希,但验证端用的版本号是从另一个地方读的,两个版本号差了 1。从那以后,我要求签名和验证必须用同一份元数据。

好了,签名流程的核心就这些。记住:哈希是基础,私钥是核心,公钥是门锁。把这三样管好了,固件安全就有了八成把握。