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
公钥文件会包含完整的椭圆曲线参数和公钥点。设备端烧录的就是这个公钥。
4.3 对固件进行签名
密钥有了,接下来就是签名。签名的本质是:对固件的哈希值用私钥加密,生成一个签名值。
流程分两步:
- 计算固件的哈希值(比如 SHA-256)。
- 用私钥对哈希值进行签名。
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)。
4.4 验签流程
设备收到固件和签名后,需要验证签名是否合法。验签的流程是:
- 用同样的哈希算法计算收到的固件的哈希值。
- 用公钥解密签名,得到原始哈希值。
- 对比两个哈希值。如果一致,说明固件未被篡改。
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 表示失败。
4.6 签名流程全景图
下面这张图把整个签名和验签的流程串起来了。你可以把它贴在工位上,每次做 OTA 安全设计时瞄一眼。
4.7 常见问题与避坑指南
最后,我把自己这些年踩过的坑整理一下,你遇到了可以直接翻出来看。
| 问题 | 现象 | 解决方案 |
|---|---|---|
| 签名长度不固定 | 每次签名结果不一样 | ECDSA 签名包含随机数 k,每次签名结果不同是正常的。验签时不要比较签名值,而是比较验签结果。 |
| 公钥格式不匹配 | 设备验签一直失败 | 检查公钥格式。OpenSSL 默认输出 PEM,设备端可能需要 DER 或原始点格式。用 openssl ec -in key.pem -pubout -outform DER 转换。 |
| 哈希算法不一致 | 签名方和验签方结果不同 | 确保两边用同样的哈希算法。我习惯在固件头里加一个字段标明哈希算法,设备端根据字段选择。 |
| 固件太大导致内存溢出 | 设备验签时崩溃 | 不要一次性加载整个固件到内存。用流式哈希,分块读取、分块更新摘要。上面的 C 代码示例就是流式处理的。 |