4、安全升级机制:固件签名与验签、加密传输(TLS/HTTPS)、防回滚保护、安全启动链
说到物联网设备的安全升级,我脑子里第一个蹦出来的词就是「信任」。你想想看,一个设备在野外跑了好几年,突然收到一个升级包,它凭什么相信这个包是厂家发的,而不是黑客塞进来的恶意代码?
这个问题,我在一个智能电表项目里吃过亏。当时设备批量部署后,发现有个漏洞需要紧急修复。结果升级包刚发出去,就有用户反馈设备变砖了。后来一查,是升级包在传输过程中被篡改了一小段数据。嗯,从那以后,我对安全升级机制就格外上心。
今天咱们就把这四块内容掰开揉碎了讲:固件签名与验签、加密传输、防回滚保护、安全启动链。这四者环环相扣,缺一个都不行。
4.1 固件签名与验签:给固件贴上「防伪标签」
说白了,签名就是给固件打个「防伪码」。设备收到固件后,先验一下这个码对不对。对,就升级;不对,直接扔掉。
我习惯用 ECDSA(椭圆曲线数字签名算法)来做签名。为什么?因为它的密钥短,算得快,特别适合资源受限的物联网设备。RSA 当然也行,但密钥长度动不动就 2048 位,对 MCU 来说有点吃不消。
具体流程是这样的:
- 签名端(服务器):用私钥对固件的哈希值进行签名,生成签名文件。
- 验签端(设备):用公钥对签名进行验证,比对哈希值是否一致。
这里有个关键点:公钥必须安全地存储在设备里。如果公钥能被篡改,那签名就形同虚设。我见过一个项目,把公钥直接写在 Flash 的明文区域,结果被逆向工程轻松改掉。后来我建议他们把公钥烧录在一次性 OTP 区域,或者用芯片自带的 eFuse 来存储。
重要提醒:签名算法建议使用 SHA-256 做哈希,搭配 ECDSA 或 Ed25519。别再用 MD5 或 SHA-1 了,这两个已经被证明不安全。
来看一段伪代码,演示验签过程:
// 设备端验签流程
uint8_t firmware_hash[32];
uint8_t signature[64];
uint8_t public_key[32];
// 1. 计算固件哈希
SHA256_Calculate(firmware_data, firmware_size, firmware_hash);
// 2. 用公钥验证签名
if (ECDSA_Verify(public_key, firmware_hash, signature) == SUCCESS) {
// 签名验证通过,允许升级
StartFirmwareUpdate();
} else {
// 签名验证失败,拒绝升级
LogError("Firmware signature invalid!");
AbortUpdate();
}
我的小技巧:签名验证最好放在 Bootloader 里做,而不是在应用程序里。因为应用程序本身可能已经被篡改了,你让一个被篡改的程序去验签,那不是搞笑吗?
4.2 加密传输(TLS/HTTPS):别让升级包在「裸奔」
签名解决了「固件是谁发的」这个问题,但没解决「固件在传输过程中有没有被偷看」的问题。你想想看,如果升级包里有设备的配置信息、密钥之类的敏感数据,被中间人抓包抓到了,那麻烦就大了。
所以,加密传输是必须的。我个人强烈推荐使用 TLS 1.3 或 HTTPS 来做传输层加密。
为什么是 TLS 1.3?因为它比 TLS 1.2 握手更快(1-RTT 甚至 0-RTT),而且去掉了那些不安全的加密套件。对于物联网设备来说,每次握手少一个来回,可能就是几秒钟的省电。
不过,TLS 在物联网设备上有个老生常谈的问题:证书管理。设备里要预置 CA 证书,还要处理证书过期、吊销等情况。我曾在某个项目中,因为设备里的 CA 证书过期了,导致所有设备都无法建立 TLS 连接,最后只能派人去现场手动更新证书。那叫一个惨。
避坑指南:我曾经遇到过设备因为系统时间不准,导致 TLS 证书验证失败。后来我加了一个「时间同步」的步骤,在建立 TLS 连接之前,先通过 NTP 或基站同步一下时间。这个问题才算彻底解决。
如果你觉得 TLS 太重了,也可以考虑用 DTLS(基于 UDP 的 TLS)或者干脆用 预共享密钥(PSK) 的方式做加密。但 PSK 有个缺点:密钥更新麻烦。所以,我一般只在资源极度受限的设备上才用 PSK。
4.3 防回滚保护:别让设备「开倒车」
防回滚,说白了就是禁止设备升级到旧版本。为什么要有这个机制?因为黑客可能会利用旧版本的漏洞来攻击设备。比如,旧版本有个已知的缓冲区溢出漏洞,黑客先让设备回滚到旧版本,然后再利用这个漏洞植入恶意代码。
我习惯的做法是:在固件头里加一个 版本号字段,设备在升级前比较当前版本和目标版本的版本号。如果目标版本号小于当前版本号,直接拒绝升级。
但这里有个坑:版本号不能是简单的递增整数。万一版本号从 1.0 跳到 2.0,然后又想回退到 1.5 呢?所以,我建议用 语义化版本号(主版本.次版本.修订号),并且规定:主版本和次版本只能递增,不能回退。
来看一个版本号比较的示例:
// 版本号结构体
typedef struct {
uint8_t major;
uint8_t minor;
uint16_t patch;
} firmware_version_t;
// 防回滚检查
bool CheckRollbackProtection(firmware_version_t current, firmware_version_t target) {
// 主版本号必须大于等于当前版本
if (target.major < current.major) return false;
if (target.major > current.major) return true;
// 次版本号必须大于等于当前版本
if (target.minor < current.minor) return false;
if (target.minor > current.minor) return true;
// 修订号可以回退(用于紧急修复)
return true;
}
重要提醒:版本号信息必须放在签名保护的区域内。如果版本号可以被随意篡改,那防回滚就是个摆设。我一般把版本号放在固件头的固定偏移处,和固件一起做签名。
4.4 安全启动链:从「根」上建立信任
安全启动链,是整个安全升级机制的基石。它的核心思想是:从芯片上电的第一条指令开始,每一级代码都要验证下一级代码的签名。这样,只要根信任是安全的,整个链条就是安全的。
典型的启动链是这样的:
- ROM Bootloader(芯片出厂固化的,不可修改)
- 验证 Flash Bootloader 的签名
- Flash Bootloader 验证 应用程序 的签名
- 应用程序验证 升级包 的签名
你看,每一级都在「接力」验证。ROM Bootloader 是信任的根,它通常是芯片厂商写死的,我们改不了。但我们可以利用它来验证我们自己写的 Flash Bootloader。
我在一个项目中,把 Flash Bootloader 分成了两个区:主 Bootloader 和 备份 Bootloader。主 Bootloader 负责正常的启动流程,备份 Bootloader 则用于恢复。如果主 Bootloader 被损坏了,芯片会自动从备份 Bootloader 启动。这个设计救过我一次——有一次升级过程中意外断电,主 Bootloader 被写坏了,但备份 Bootloader 还在,设备成功恢复了。
我的建议:安全启动链的每一级,都要做「失败回退」处理。比如,应用程序签名验证失败,不要直接死机,而是回退到 Bootloader 模式,等待用户重新升级。这样至少设备不会变砖。
4.5 四者如何协同工作?
这四块内容不是孤立的,它们是一个整体。我画个简单的流程图给你看:
设备上电
↓
ROM Bootloader 启动
↓ (验证 Flash Bootloader 签名)
Flash Bootloader 启动
↓ (验证应用程序签名)
应用程序运行
↓ (通过 HTTPS 下载升级包)
下载完成后验签
↓ (验证固件签名)
检查版本号(防回滚)
↓ (全部通过)
写入新固件
↓
重启,进入安全启动链验证新固件
你看,每一步都在「信任链」上。只要有一个环节断了,升级就失败。这样虽然严格了点,但安全嘛,怎么小心都不为过。
最后提醒一句:安全升级机制不是「一次性」的工作。随着时间推移,签名算法可能会被破解,证书可能会过期,版本号策略可能需要调整。所以,我建议在设备设计之初就预留好「安全策略更新」的接口。比如,支持远程更新公钥、支持切换签名算法等。别等到设备部署了才发现,想改都改不了。
好了,这一章的内容就到这里。下一章咱们聊聊「升级失败后的恢复策略」,包括双备份区、看门狗超时恢复、以及远程救砖方案。这些东西在实际项目中特别实用,到时候我会分享几个我踩过的坑,保证让你少走弯路。