4. 固件签名实践:使用OpenSSL生成密钥对、对固件进行签名、验签流程代码实现

好了,咱们进入实战环节。

前面几章讲了理论,讲了攻击手法,也讲了签名能解决什么问题。但说一千道一万,你得亲手敲一遍代码,才能真正理解签名是怎么回事。我见过太多工程师,PPT上讲得头头是道,一到现场配密钥、跑签名脚本就卡壳。

这一章,咱们就用 OpenSSL 把整个流程走一遍。从生成密钥对,到给固件签名,再到验签,每一步我都会给出完整的命令和代码。你跟着敲一遍,基本就掌握了。

4.1 为什么选 OpenSSL?

你可能要问:市面上签名工具那么多,为什么非要用 OpenSSL?

我的理由有三点:

  • 开源且跨平台:Linux、Windows、macOS 都能跑,嵌入式设备上也能交叉编译。
  • 算法支持全面:RSA、ECDSA、Ed25519 都有,能满足不同安全等级的需求。
  • 社区成熟:遇到坑了,Stack Overflow 上一搜一大把解决方案。我自己踩过的坑,基本都能找到答案。

当然,OpenSSL 的 API 设计确实有点「历史感」,但咱们只用到它的核心功能,没那么复杂。

4.2 生成密钥对

签名之前,你得先有一对钥匙:私钥自己藏好,公钥发给设备。

我个人习惯用 ECDSA 算法,尤其是 P-256 曲线。为什么?因为它的密钥比 RSA 短得多,签名速度也快,在资源受限的 IoT 设备上特别友好。RSA 2048 位的签名有 256 字节,而 ECDSA P-256 只有 64 字节。你想想看,每次 OTA 都要传签名,省下来的带宽可不是一星半点。

4.2.1 生成 ECDSA P-256 私钥

openssl ecparam -genkey -name prime256v1 -out firmware_private.pem

这条命令会生成一个 PEM 格式的私钥文件。里面的内容长这样:

-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIJ7Qz3R7Vv...(省略)
-----END EC PRIVATE KEY-----

注意:这个文件就是你的命根子。一旦泄露,攻击者就能伪造你的固件签名。我在项目中见过有人把私钥直接提交到 Git 仓库里……嗯,那场面,简直是灾难。

4.2.2 从私钥导出公钥

openssl ec -in firmware_private.pem -pubout -out firmware_public.pem

公钥文件会包含完整的椭圆曲线参数和公钥点。设备端烧录的就是这个公钥。

我的建议:生产环境中,私钥一定要放在硬件安全模块(HSM)或安全飞地中。别图省事直接存文件系统里。我曾经帮一个客户做安全审计,发现他们的私钥就明文放在 /etc/keys/ 目录下,权限还是 777……当场血压就上来了。

4.3 对固件进行签名

密钥有了,接下来就是签名。签名的本质是:对固件的哈希值用私钥加密,生成一个签名值。

流程分两步:

  1. 计算固件的哈希值(比如 SHA-256)。
  2. 用私钥对哈希值进行签名。

OpenSSL 可以一步到位:

openssl dgst -sha256 -sign firmware_private.pem -out firmware.bin.sig firmware.bin

这条命令做了三件事:

  • 读取 firmware.bin 文件
  • 计算它的 SHA-256 哈希
  • 用私钥对哈希签名,结果写入 firmware.bin.sig

生成的 .sig 文件是二进制格式,大小固定。对于 ECDSA P-256,签名长度是 64 字节(两个 32 字节的大整数 r 和 s)。

踩坑提醒:我曾经犯过一个低级错误——签名时忘了指定哈希算法。OpenSSL 默认用 SHA-1,而 SHA-1 现在已经不安全了。所以每次签名,我都强制加上 -sha256 参数,养成习惯。

4.4 验签流程

设备收到固件和签名后,需要验证签名是否合法。验签的流程是:

  1. 用同样的哈希算法计算收到的固件的哈希值。
  2. 用公钥解密签名,得到原始哈希值。
  3. 对比两个哈希值。如果一致,说明固件未被篡改。

OpenSSL 验签命令:

openssl dgst -sha256 -verify firmware_public.pem -signature firmware.bin.sig firmware.bin

如果验签通过,你会看到:

Verified OK

如果失败:

Verification Failure

4.5 完整的 C 语言验签代码示例

命令行的验签只是测试用。真正的设备端,你得用代码实现。下面是我常用的一个验签函数,基于 OpenSSL 的 EVP API:

#include <openssl/evp.h>
#include <openssl/pem.h>
#include <stdio.h>
#include <string.h>

int verify_firmware(const char *firmware_path, 
                    const char *signature_path, 
                    const char *pubkey_path) {
    FILE *f_fw = fopen(firmware_path, "rb");
    FILE *f_sig = fopen(signature_path, "rb");
    FILE *f_key = fopen(pubkey_path, "rb");
    
    if (!f_fw || !f_sig || !f_key) {
        printf("Failed to open files\n");
        return -1;
    }
    
    // 读取公钥
    EVP_PKEY *pkey = PEM_read_PUBKEY(f_key, NULL, NULL, NULL);
    if (!pkey) {
        printf("Failed to read public key\n");
        return -1;
    }
    
    // 读取签名
    unsigned char sig[64];
    size_t sig_len = fread(sig, 1, sizeof(sig), f_sig);
    
    // 读取固件并计算哈希
    unsigned char buffer[4096];
    size_t bytes;
    EVP_MD_CTX *mdctx = EVP_MD_CTX_new();
    EVP_DigestInit_ex(mdctx, EVP_sha256(), NULL);
    
    while ((bytes = fread(buffer, 1, sizeof(buffer), f_fw)) > 0) {
        EVP_DigestUpdate(mdctx, buffer, bytes);
    }
    
    unsigned char hash[32];
    unsigned int hash_len;
    EVP_DigestFinal_ex(mdctx, hash, &hash_len);
    EVP_MD_CTX_free(mdctx);
    
    // 验签
    EVP_MD_CTX *verify_ctx = EVP_MD_CTX_new();
    EVP_DigestVerifyInit(verify_ctx, NULL, EVP_sha256(), NULL, pkey);
    EVP_DigestVerifyUpdate(verify_ctx, hash, hash_len);
    
    int ret = EVP_DigestVerifyFinal(verify_ctx, sig, sig_len);
    
    EVP_MD_CTX_free(verify_ctx);
    EVP_PKEY_free(pkey);
    
    fclose(f_fw);
    fclose(f_sig);
    fclose(f_key);
    
    return (ret == 1) ? 0 : -1;
}

这段代码的核心逻辑就是:读公钥 → 读签名 → 算哈希 → 验签。返回值 0 表示验签通过,-1 表示失败。

关键点:注意看,我用了 EVP_DigestVerify 系列函数,而不是直接调用 ECDSA_verify。EVP API 是 OpenSSL 推荐的高级接口,支持多种算法,代码可移植性更好。你以后换 RSA 或 Ed25519,只需要改 EVP_sha256() 这个参数,其他代码不用动。

4.6 签名流程全景图

下面这张图把整个签名和验签的流程串起来了。你可以把它贴在工位上,每次做 OTA 安全设计时瞄一眼。

固件签名与验签全景流程 签名方(服务器) 固件 firmware.bin 私钥 private.pem SHA-256 哈希 私钥签名 OTA 传输 验签方(设备) 固件 firmware.bin 公钥 public.pem SHA-256 哈希 公钥验签 验签通过 验签失败 关键点 • 私钥永远不出服务器 • 公钥预置在设备中 • 签名随固件一起传输

4.7 常见问题与避坑指南

最后,我把自己这些年踩过的坑整理一下,你遇到了可以直接翻出来看。

问题 现象 解决方案
签名长度不固定 每次签名结果不一样 ECDSA 签名包含随机数 k,每次签名结果不同是正常的。验签时不要比较签名值,而是比较验签结果。
公钥格式不匹配 设备验签一直失败 检查公钥格式。OpenSSL 默认输出 PEM,设备端可能需要 DER 或原始点格式。用 openssl ec -in key.pem -pubout -outform DER 转换。
哈希算法不一致 签名方和验签方结果不同 确保两边用同样的哈希算法。我习惯在固件头里加一个字段标明哈希算法,设备端根据字段选择。
固件太大导致内存溢出 设备验签时崩溃 不要一次性加载整个固件到内存。用流式哈希,分块读取、分块更新摘要。上面的 C 代码示例就是流式处理的。
血的教训:我曾经在一个项目中,把私钥和公钥都放在了固件更新包里,想着「方便调试」。结果产品上线第一天就被安全团队抓了个正着。私钥一旦泄露,整个产品的安全体系就崩塌了。记住:私钥永远不要离开签名服务器。