4、固件签名与安全:数字签名原理、哈希校验(SHA256)、固件加密(AES)

说到嵌入式设备的OTA升级,安全这块儿,我敢说,是很多人容易忽略的「最后一公里」。你想想看,你的设备辛辛苦苦连上网,下载了一个固件包,结果这个包被人篡改过,甚至植入了恶意代码——那后果不堪设想。

我早年做一款智能家居网关时,就吃过这个亏。当时为了赶工期,OTA升级只做了简单的版本号校验,结果有用户反馈设备升级后频繁重启。后来一查,是有人在局域网里做了中间人攻击,替换了固件包。从那以后,我再也不敢轻视固件安全了。

这一章,我们就来聊聊固件签名与安全的三个核心环节:数字签名原理、哈希校验(SHA256)、固件加密(AES)。说白了,就是解决三个问题:

  • 这个固件是谁发的?(身份认证)
  • 固件内容有没有被改过?(完整性校验)
  • 固件内容能不能被别人看到?(机密性保护)

4.1 哈希校验(SHA256)—— 固件的「指纹」

哈希校验,你可以把它理解成给固件文件算一个「指纹」。这个指纹是唯一的,哪怕你只改了一个bit,算出来的哈希值都会完全不一样。

我个人习惯用SHA256,虽然比MD5或SHA1计算量稍大,但安全性高得多。现在很多MCU都内置了硬件SHA加速模块,算起来其实很快。

核心要点:哈希是单向的。你无法从哈希值反推出原始数据。所以它只能用来做完整性校验,不能用来加密。

在OTA流程中,我们通常这样做:

  1. 在服务器端,对固件文件计算SHA256哈希值。
  2. 将哈希值附加到固件包中,或者单独下发。
  3. 设备端下载完固件后,重新计算一次SHA256。
  4. 比对两个哈希值。如果一致,说明固件没被篡改。

代码示例(设备端,C语言):

#include "mbedtls/sha256.h"

int verify_firmware_hash(const uint8_t *firmware, size_t fw_len, 
                         const uint8_t *expected_hash) {
    uint8_t computed_hash[32];
    mbedtls_sha256_context ctx;
    
    mbedtls_sha256_init(&ctx);
    mbedtls_sha256_starts(&ctx, 0); // 0 表示 SHA256,非 SHA224
    mbedtls_sha256_update(&ctx, firmware, fw_len);
    mbedtls_sha256_finish(&ctx, computed_hash);
    mbedtls_sha256_free(&ctx);
    
    // 常量时间比较,防止时序攻击
    return memcmp(computed_hash, expected_hash, 32) == 0;
}

避坑指南:我曾经在项目里直接用 memcmp 比较哈希值,结果被安全审计指出存在「时序攻击」风险。攻击者可以通过测量比较时间,逐步猜出哈希值。建议使用 mbedtls_safer_memcmp 或自己实现一个常量时间比较函数。

4.2 数字签名原理 —— 给固件盖上「公章」

哈希校验只能保证固件没被改过,但解决不了「这个固件到底是不是官方发布的」这个问题。你想想看,攻击者完全可以自己算一个哈希值,替换掉原来的。这时候就需要数字签名了。

数字签名的核心是非对称加密。简单来说:

  • 服务器用私钥对固件的哈希值进行签名。
  • 设备端用公钥验证签名。
  • 私钥只有服务器持有,公钥可以公开。

这样一来,只要设备端验证签名通过,就能确认这个固件确实来自持有私钥的官方服务器。

我常用的签名算法是ECDSA(椭圆曲线数字签名算法),相比RSA,它的密钥更短,签名速度更快,非常适合资源受限的嵌入式设备。

签名流程(服务器端,Python示例):

from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec, utils
from cryptography.hazmat.primitives import serialization

# 加载私钥
with open("private_key.pem", "rb") as f:
    private_key = serialization.load_pem_private_key(f.read(), password=None)

# 计算固件哈希
firmware_data = open("firmware.bin", "rb").read()
digest = hashes.Hash(hashes.SHA256())
digest.update(firmware_data)
fw_hash = digest.finalize()

# 签名
signature = private_key.sign(fw_hash, ec.ECDSA(utils.Prehashed(hashes.SHA256())))

# 将签名附加到固件包中
with open("firmware_signed.bin", "wb") as f:
    f.write(firmware_data)
    f.write(signature)

验证流程(设备端,C语言,使用mbedTLS):

#include "mbedtls/pk.h"
#include "mbedtls/ecdsa.h"

int verify_firmware_signature(const uint8_t *firmware, size_t fw_len,
                              const uint8_t *signature, size_t sig_len,
                              const uint8_t *public_key_der, size_t pk_len) {
    mbedtls_pk_context pk;
    mbedtls_pk_init(&pk);
    
    // 解析公钥
    mbedtls_pk_parse_public_key(&pk, public_key_der, pk_len);
    
    // 计算固件哈希
    uint8_t hash[32];
    mbedtls_sha256(firmware, fw_len, hash, 0);
    
    // 验证签名
    int ret = mbedtls_pk_verify(&pk, MBEDTLS_MD_SHA256, 
                                hash, 0, signature, sig_len);
    
    mbedtls_pk_free(&pk);
    return ret == 0 ? 0 : -1;
}

重要提醒:公钥一定要烧死在设备的一次性可编程(OTP)区域,或者用硬件安全模块(HSM/SE)保护。如果公钥可以被篡改,那整个签名体系就形同虚设。我见过有厂商把公钥放在普通Flash里,结果被逆向出来,伪造了签名固件。

4.3 固件加密(AES)—— 给固件穿上「隐身衣」

哈希和签名解决了完整性和身份认证问题,但固件本身还是明文传输的。如果你的固件里包含了算法逻辑、通信密钥或者敏感配置,那别人抓包就能直接看到。

这时候就需要加密了。我推荐使用AES-128-GCM。为什么是GCM模式?因为它同时提供了加密和认证功能,能检测出密文是否被篡改。

加密流程(服务器端):

from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
import os

# 生成随机IV(12字节用于GCM)
iv = os.urandom(12)
key = bytes.fromhex("你的128位AES密钥")  # 16字节

# 加密固件
cipher = Cipher(algorithms.AES(key), modes.GCM(iv))
encryptor = cipher.encryptor()
ciphertext = encryptor.update(firmware_data) + encryptor.finalize()

# 获取认证标签(16字节)
tag = encryptor.tag

# 打包:IV + 密文 + 标签
encrypted_package = iv + ciphertext + tag

解密流程(设备端):

#include "mbedtls/aes.h"
#include "mbedtls/gcm.h"

int decrypt_firmware(const uint8_t *encrypted_pkg, size_t pkg_len,
                     uint8_t *output, size_t *out_len,
                     const uint8_t *key) {
    mbedtls_gcm_context ctx;
    mbedtls_gcm_init(&ctx);
    
    // 解析包:前12字节是IV,最后16字节是tag
    const uint8_t *iv = encrypted_pkg;
    const uint8_t *ciphertext = encrypted_pkg + 12;
    size_t ct_len = pkg_len - 12 - 16;
    const uint8_t *tag = encrypted_pkg + 12 + ct_len;
    
    mbedtls_gcm_setkey(&ctx, MBEDTLS_CIPHER_ID_AES, key, 128);
    
    int ret = mbedtls_gcm_auth_decrypt(&ctx, ct_len, iv, 12,
                                       NULL, 0,  // 附加认证数据(AAD)
                                       tag, 16,
                                       ciphertext, output);
    
    mbedtls_gcm_free(&ctx);
    
    if (ret == 0) {
        *out_len = ct_len;
        return 0;
    }
    return -1;  // 认证失败,数据被篡改
}

我的经验:AES密钥的管理是最大的难点。我建议使用密钥派生函数(KDF),比如用设备的唯一ID(如MCU的UID)加上一个服务器端的主密钥,通过HKDF派生出一个设备专属的加密密钥。这样即使一个设备的密钥泄露,也不会影响其他设备。

4.4 完整的OTA安全流程

在实际项目中,我通常把这三个环节组合起来,形成一个完整的「签名+加密」流程:

步骤 操作 目的
1 服务器对固件计算SHA256哈希 生成固件指纹
2 服务器用私钥对哈希值进行ECDSA签名 身份认证
3 服务器用AES-GCM加密固件+签名 机密性保护
4 设备端下载加密包,用AES-GCM解密 解密并验证完整性
5 设备端用公钥验证签名 确认固件来源可信
6 设备端计算SHA256,与签名中的哈希比对 二次确认固件完整性

嗯,这里要注意:步骤5和6的顺序不能乱。先验证签名,再校验哈希。因为签名验证通过后,你才能信任签名里附带的哈希值。

总结一句话:哈希保完整,签名保来源,加密保机密。三者缺一不可。

我记得有一次给客户做方案评审,对方说「我们只做哈希校验就够了,反正攻击者没那么闲」。结果三个月后,他们的设备被批量刷入了挖矿程序。从那以后,我再也没见过有人敢省略签名和加密了。

安全这件事,说白了就是「防君子也防小人」。多花一周时间把签名和加密加上去,能省掉后面无数个加班的夜晚。