升级包制作与签名:升级包结构、差分升级与全量升级、签名与校验机制

各位同学,咱们今天聊点实在的。升级包怎么做,怎么签,怎么验。这活儿看着简单,但坑是真不少。我见过太多团队,算法选得挺好,结果打包环节出了岔子,整包刷进去设备直接变砖。嗯,咱们一步步拆开看。

升级包结构:骨架搭对了,后面才不慌

一个标准的 OTA 升级包,说白了就是一个自解压的容器。设备拿到它,能自己拆开、校验、然后刷进去。我个人习惯把包分成三个区:

  • 包头(Header):魔数、版本号、包类型、签名算法 ID、载荷长度、校验和。固定长度,一般 64 或 128 字节。
  • 载荷(Payload):真正的升级数据。可以是全量镜像,也可以是差分补丁。
  • 签名区(Signature):对包头+载荷的签名值。长度取决于算法,RSA-2048 就是 256 字节。

关键点:签名区必须放在最后。为什么?因为设备可以边下载边校验,不用等整个包落地。我在项目中遇到过把签名放前面的设计,结果下载器得先解析签名再回头校验,白白多了一轮 I/O。

下面这张图,把包结构画清楚了:

OTA 升级包结构 包头 (Header) — 64 字节 载荷 (Payload) — 全量镜像 / 差分补丁 压缩格式:gzip / lzma / zstd 签名区 (Signature) — RSA-2048 / ECDSA 校验顺序:先验签名 → 再解包 → 最后刷写

全量升级 vs 差分升级:选哪个?

这个问题,每次做方案评审都会有人问。我的回答很简单:看你的网络带宽和存储余量。

对比维度 全量升级 差分升级
包体大小 完整镜像,通常 50~500 MB 仅变更部分,通常 1~50 MB
制作复杂度 低,直接打包镜像即可 高,需要新旧版本比对算法
适用场景 首次刷机、跨大版本升级 小版本迭代、补丁修复
失败风险 低,整包替换 中,依赖旧版本完整性
存储要求 需要双分区(A/B)或足够缓存 可单分区 + 补丁应用缓存

我的经验:差分升级能省流量,但别迷信它。我曾经在一个项目里用 bsdiff 做差分,结果旧版本有 0.1% 的比特翻转,差分包应用后系统直接崩溃。从那以后,我坚持差分包必须带 CRC 校验,并且设备端要做旧版本完整性预检。

差分算法怎么选?

市面上主流的差分算法就几种,我列个表,你们按需取用:

算法 压缩率 内存消耗 适用场景
bsdiff 极高 高(需加载新旧镜像) 固件、内核镜像
hdiffpatch 高 中 文件系统、资源包
xdelta3 中 低 通用二进制
zchunk 中 低 流式传输、断点续传

我个人习惯:固件用 bsdiff,因为它对二进制差异的压缩率确实能打;文件系统用 hdiffpatch,它对大文件分块处理更友好。你想想看,一个 200 MB 的内核镜像,bsdiff 打出来可能只有 5 MB,这流量省得值不值?

签名与校验机制:信任的基石

升级包不签名,等于把家门钥匙挂在大街上。攻击者可以伪造一个恶意包,让设备执行任意代码。签名机制的核心就三件事:

  1. 谁签的? — 身份认证
  2. 签的是啥? — 内容完整性
  3. 能不能被篡改? — 防抵赖

常见的签名方案有两种:

  • RSA-PSS + SHA-256:成熟、广泛支持,但签名长(256 字节),验签慢。
  • ECDSA + SHA-256:签名短(64 字节),验签快,适合资源受限设备。

注意:别用 RSA-PKCS1v1.5 签名!它存在 Bleichenbacher 攻击风险。我在 2018 年审计过一个项目,他们用的就是 PKCS1v1.5,我建议立刻切换到 PSS。安全无小事,别省那点计算量。

签名流程:从制作到验证

制作端的流程,我习惯写成脚本:

# 1. 生成密钥对(仅一次)
openssl genpkey -algorithm RSA -out private.pem -pkeyopt rsa_keygen_bits:2048
openssl rsa -pubout -in private.pem -out public.pem

# 2. 计算载荷哈希
sha256sum payload.bin > payload.hash

# 3. 签名(对哈希签名,不是对整个文件)
openssl pkeyutl -sign -in payload.hash -inkey private.pem -out signature.bin

# 4. 打包
cat header.bin payload.bin signature.bin > ota_package.bin

设备端的校验逻辑,我贴一段伪代码,你们感受一下:

// 设备端验签流程
int verify_ota_package(uint8_t *package, size_t len) {
    // 1. 解析包头,提取签名算法和载荷长度
    ota_header_t *hdr = (ota_header_t *)package;
    
    // 2. 计算载荷的 SHA-256 哈希
    uint8_t hash[32];
    sha256(package + sizeof(ota_header_t), hdr->payload_len, hash);
    
    // 3. 用公钥验签
    uint8_t *sig = package + sizeof(ota_header_t) + hdr->payload_len;
    if (rsa_verify_pss(hash, sig, public_key) != 0) {
        return -1; // 签名无效,拒绝升级
    }
    
    // 4. 签名通过,继续解包和刷写
    return 0;
}

核心原则:永远先验签,再解包。不要先解包再验签,那样攻击者可以用畸形包触发解压器的漏洞。我在一个 IoT 项目里见过这种设计,幸好发现得早,不然就是批量 RCE。

密钥管理:最容易被忽视的一环

签名算法再强,密钥泄露了也是白搭。我建议你们做到以下几点:

  • 私钥离线存储:放在 HSM 或离线机器上,只在打包时挂载。
  • 公钥烧录在设备只读区:别放文件系统,防止被替换。
  • 密钥轮转:每两年换一次,旧密钥只用来验旧包,不签新包。
  • 测试密钥 vs 生产密钥:严格分离,测试密钥的签名在产线上必须被拦截。

一个小技巧:在公钥的哈希值里混入设备唯一 ID,这样即使攻击者提取了公钥,也无法在其他设备上复用。这叫「公钥绑定」,我在车机项目里用过,效果不错。

避坑指南:我踩过的几个雷

  • 签名算法不匹配:制作端用 RSA-2048,设备端只支持 RSA-1024。嗯,这种低级错误我见过不止一次。解决方案:在包头里显式声明算法 ID,设备端做兼容性检查。
  • 哈希计算范围不一致:制作端只对载荷哈希,设备端却把包头也算进去了。两边对不上,验签必失败。统一规则:签名对象 = 包头 + 载荷。
  • 时间戳校验太严格:设备时钟不准,签名过期了。我建议时间戳只做参考,不做硬阻断,或者用版本号递增代替时间。
  • 差分包依赖旧版本:设备当前版本和差分包基准版本不一致,应用后系统崩溃。解决方案:在包头里记录基准版本号,设备端先比对,不一致就拒绝。