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 固件签名流程:服务器端怎么做?

签名流程,说白了就是「给固件盖个章」。下面是标准步骤:

  1. 编译固件:生成二进制文件 firmware.bin。
  2. 计算哈希:对 firmware.bin 做 SHA256,得到 32 字节摘要。
  3. 签名摘要:用私钥对摘要做 RSA 或 ECC 签名,生成签名数据(通常 256~512 字节)。
  4. 打包:将 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 固件验签流程:设备端怎么做?

设备端验签,就是「检查这个章是不是真的」。流程如下:

  1. 接收固件:通过 OTA 下载 firmware_signed.bin。
  2. 分离数据:从末尾提取签名长度和签名数据,剩余部分为原始固件。
  3. 计算哈希:对原始固件做 SHA256,得到摘要。
  4. 验签:用预置的公钥对摘要和签名进行验证。
  5. 结果判断:验签通过 → 写入 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 整体流程示意图

下面这张图,把签名和验签的完整流程串起来了。建议你保存下来,以后做方案设计时对照着看。

固件签名与验签流程图 固件签名与验签完整流程 签名(服务器端) 固件 binary SHA256 哈希 32 字节摘要 私钥签名 打包:固件 + 签名 验签(设备端) 接收固件包 分离固件与签名 SHA256 哈希 公钥验签 通过 → 写入 / 失败 → 丢弃 OTA 传输 关键安全约束 私钥必须离线存储 | 公钥必须烧录在设备只读区 | 签名算法必须使用标准库

2.7 避坑指南:我踩过的那些坑

做了这么多年嵌入式安全,有些坑我是一路踩过来的。分享给你,希望你能绕过去:

  • 签名数据不包含版本号:攻击者可以拿旧版本的合法固件做降级攻击。我建议签名时把固件版本号也一起哈希进去。
  • 验签失败后不记录日志:设备静默丢弃固件,你根本不知道攻击者在尝试什么。一定要记录失败次数和时间戳。
  • 公钥存储在可写区域:这个前面说过了,等于没锁门。用 OTP 或 eFuse,一次写入,永不更改。
  • 忽略侧信道攻击:如果攻击者能测量设备的功耗或电磁辐射,可能通过时序分析推断出密钥。用「恒定时间比较」来验签,不要用 memcmp。

一句话总结:签名验签不是可选项,是 OTA 安全的底线。没有签名的 OTA,就像没上锁的保险柜——不是会不会被撬,而是什么时候被撬。