2. 固件签名与验签:数字签名原理、哈希算法(SHA256)、非对称加密(RSA/ECC)、固件签名流程、固件验签流程
各位好,我是老周。今天咱们聊固件签名与验签。这玩意儿说白了,就是给你的固件贴上一张「防伪标签」。没有它,你的设备就是个任人打扮的小姑娘——谁都能往里面塞恶意代码。
我刚开始做嵌入式安全那会儿,总觉得签名验签是「大厂才需要的东西」。直到有一次,客户的产品在野外被攻击者用伪造固件刷成了「矿机」……嗯,从那以后,我再也不敢省略这一步了。
2.1 数字签名原理:为什么它能防伪造?
数字签名,本质上是一道数学题。它解决三个核心问题:
- 完整性:固件有没有被篡改?
- 真实性:固件是不是来自合法的发布者?
- 不可否认性:发布者不能事后抵赖说「我没发过这个固件」。
你想想看,现实世界里我们怎么防伪?签名、印章、指纹。数字世界也一样,只不过用的是密钥对——私钥签名,公钥验签。
核心原则:私钥永远留在服务器端,公钥烧录在设备端。私钥泄露 = 整个产品线沦陷。
我在一个物联网项目中见过有人把私钥硬编码在固件里……结果被逆向出来,整个批次的产品全部召回。血的教训。
2.2 哈希算法(SHA256):固件的「指纹」
固件可能很大,几兆甚至几十兆。直接用非对称加密去签名整个固件?太慢了,而且浪费算力。所以我们先对固件做一次哈希运算,生成一个固定长度的摘要。
SHA256 是目前最常用的哈希算法。它有几个特点:
- 固定输出:不管输入多大,输出都是 256 位(32 字节)。
- 单向性:从摘要反推原始数据,理论上不可能。
- 雪崩效应:改一个比特,摘要就面目全非。
小提示:SHA256 的碰撞概率极低。低到什么程度?理论上,你要尝试 2^128 次才能找到两个不同输入产生相同输出。这个数字比宇宙中的原子数还大。
我曾经踩过一个坑:用 MD5 做固件哈希。后来发现 MD5 已经被破解了,攻击者可以构造两个不同固件产生相同 MD5 值。赶紧全部切换到 SHA256,再也不敢偷懒了。
2.3 非对称加密(RSA / ECC):公钥与私钥的博弈
非对称加密,就是一对钥匙:一把公开,一把私密。公钥加密的数据只能用私钥解密;反过来,私钥签名的数据只能用公钥验签。
| 特性 | RSA | ECC(椭圆曲线) |
|---|---|---|
| 密钥长度 | 2048 / 4096 位 | 256 / 384 位 |
| 签名速度 | 较慢 | 较快 |
| 验签速度 | 较快 | 较慢 |
| 安全性(同等强度) | 2048 位 ≈ ECC 256 位 | 256 位 ≈ RSA 2048 位 |
| 资源消耗 | 高(适合服务器) | 低(适合嵌入式设备) |
我个人习惯:服务器端用 RSA 2048,因为兼容性好,库支持广泛;嵌入式设备端用 ECC 256,因为密钥短、省 Flash、省 RAM。你想想看,一个只有 64KB Flash 的 MCU,要存 2048 位的 RSA 公钥?太奢侈了。
注意:ECC 的曲线选择很重要。不要自己发明曲线,用标准曲线(如 secp256r1 / NIST P-256)。我见过有人用自定义曲线,结果被侧信道攻击打得体无完肤。
2.4 固件签名流程:服务器端怎么做?
签名流程,说白了就是「给固件盖个章」。下面是标准步骤:
- 编译固件:生成二进制文件
firmware.bin。 - 计算哈希:对
firmware.bin做 SHA256,得到 32 字节摘要。 - 签名摘要:用私钥对摘要做 RSA 或 ECC 签名,生成签名数据(通常 256~512 字节)。
- 打包:将
firmware.bin+ 签名数据 + 公钥证书(可选)打包成发布固件。
代码示例(伪代码,但逻辑真实):
// 服务器端签名流程(Python 风格)
import hashlib
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa, padding
# 1. 读取固件
with open('firmware.bin', 'rb') as f:
firmware_data = f.read()
# 2. 计算 SHA256 哈希
digest = hashlib.sha256(firmware_data).digest()
# 3. 加载私钥(安全存储!)
with open('private_key.pem', 'rb') as f:
private_key = serialization.load_pem_private_key(f.read(), password=None)
# 4. 签名
signature = private_key.sign(
digest,
padding.PKCS1v15(),
hashes.SHA256()
)
# 5. 打包:固件 + 签名 + 公钥
with open('firmware_signed.bin', 'wb') as f:
f.write(firmware_data)
f.write(len(signature).to_bytes(2, 'big')) # 签名长度
f.write(signature) # 签名数据
经验之谈:签名数据最好放在固件末尾,而不是开头。这样 OTA 下载时可以边下载边验签,不用等全部下载完再处理。我之前的项目就是这么优化的,下载速度体验提升明显。
2.5 固件验签流程:设备端怎么做?
设备端验签,就是「检查这个章是不是真的」。流程如下:
- 接收固件:通过 OTA 下载
firmware_signed.bin。 - 分离数据:从末尾提取签名长度和签名数据,剩余部分为原始固件。
- 计算哈希:对原始固件做 SHA256,得到摘要。
- 验签:用预置的公钥对摘要和签名进行验证。
- 结果判断:验签通过 → 写入 Flash;验签失败 → 丢弃固件,记录错误日志。
代码示例(嵌入式 C 风格):
// 设备端验签流程(C 语言风格)
#include "sha256.h"
#include "rsa.h"
int verify_firmware(uint8_t *buffer, uint32_t size) {
// 1. 提取签名(假设签名在末尾,长度 2 字节)
uint16_t sig_len = (buffer[size-2] << 8) | buffer[size-1];
uint8_t *signature = buffer + size - 2 - sig_len;
uint32_t fw_size = size - 2 - sig_len;
// 2. 计算固件哈希
uint8_t digest[32];
sha256_calculate(buffer, fw_size, digest);
// 3. 验签(公钥预置在代码中)
int result = rsa_verify(
digest, 32,
signature, sig_len,
public_key, PUBLIC_KEY_LEN
);
if (result == 0) {
// 验签通过
return 0;
} else {
// 验签失败
return -1;
}
}
重要提醒:公钥一定要存储在设备的只读区域(如 OTP、eFuse、安全元件)。如果公钥可以被篡改,那签名验签就形同虚设。我曾经见过一个产品,公钥存在外部 Flash 里……攻击者直接替换公钥,然后用自己的私钥签名固件,轻松绕过。
2.6 整体流程示意图
下面这张图,把签名和验签的完整流程串起来了。建议你保存下来,以后做方案设计时对照着看。
2.7 避坑指南:我踩过的那些坑
做了这么多年嵌入式安全,有些坑我是一路踩过来的。分享给你,希望你能绕过去:
- 签名数据不包含版本号:攻击者可以拿旧版本的合法固件做降级攻击。我建议签名时把固件版本号也一起哈希进去。
- 验签失败后不记录日志:设备静默丢弃固件,你根本不知道攻击者在尝试什么。一定要记录失败次数和时间戳。
- 公钥存储在可写区域:这个前面说过了,等于没锁门。用 OTP 或 eFuse,一次写入,永不更改。
- 忽略侧信道攻击:如果攻击者能测量设备的功耗或电磁辐射,可能通过时序分析推断出密钥。用「恒定时间比较」来验签,不要用
memcmp。
一句话总结:签名验签不是可选项,是 OTA 安全的底线。没有签名的 OTA,就像没上锁的保险柜——不是会不会被撬,而是什么时候被撬。