4、密码学基础(下):数字签名(ECDSA)、消息认证码(HMAC)、密钥派生函数(KDF)与固件完整性验证

上一讲我们聊了哈希和对称加密,算是把地基打牢了。但光有这些,还不足以解决固件升级中的两个核心问题:谁签发的这个固件?以及固件在传输过程中有没有被篡改?

说白了,哈希只能保证数据没变,但不能证明数据是谁发的。我当年第一次做OTA升级时,就踩过这个坑——只校验了哈希,结果被中间人换了个恶意固件,哈希也跟着换了,系统照样升级成功。嗯,那次之后我才真正理解,完整性和认证性是两码事。

今天我们就来补上这块拼图:数字签名、消息认证码、密钥派生函数,以及它们怎么组合起来保护固件。

4.1 数字签名(ECDSA)—— 固件的「身份证+防伪标签」

数字签名解决的是不可否认性和来源认证。你想想看,固件从服务器下发到设备,设备怎么知道这个固件真的是官方发布的?靠的就是签名。

ECDSA(椭圆曲线数字签名算法)是目前嵌入式领域最常用的签名算法。相比RSA,它密钥更短、计算更快,特别适合资源受限的MCU。

4.1.1 签名与验签流程

整个过程分两步:

  1. 签名(服务端做):用私钥对固件哈希值签名,生成签名值。
  2. 验签(设备端做):用公钥解密签名,比对哈希值是否一致。

我习惯把私钥比作「印章」,公钥比作「印鉴样本」。印章只有官方有,印鉴样本公开给所有人。你拿到的文件上盖了章,拿印鉴样本一对,就知道是不是真的。

关键点:设备端只存公钥,不存私钥。私钥一旦泄露,整个产品线的安全就崩了。我在项目中见过有人把私钥硬编码在编译脚本里,差点被提交到Git仓库……

4.1.2 曲线选择与密钥长度

嵌入式环境常用的曲线:

曲线 密钥长度 安全等级 典型场景
secp256r1 (P-256) 256位 128位 主流MCU,STM32/NXP
secp384r1 (P-384) 384位 192位 高安全要求,车规级
Curve25519 (X25519) 256位 128位 性能优先,IoT设备

我个人建议:新项目优先选secp256r1。它被广泛支持,硬件加速器也多,性能和安全之间平衡得最好。

4.1.3 固件签名验证的典型代码片段

// 伪代码:设备端验签流程
uint8_t firmware_hash[32];
uint8_t signature[64];   // ECDSA P-256签名值
uint8_t public_key[64];  // 公钥(x, y坐标)

// 1. 计算固件哈希
sha256_calc(firmware, firmware_len, firmware_hash);

// 2. 验签
int ret = ecdsa_verify(
    public_key,
    firmware_hash,
    signature
);

if (ret == 0) {
    // 签名验证通过,允许升级
    start_firmware_update();
} else {
    // 签名验证失败,拒绝升级
    report_error("Firmware signature invalid!");
    abort_update();
}

注意:验签前一定要先校验公钥的完整性。我曾经遇到过一种攻击——攻击者替换了设备上的公钥,然后用自己生成的私钥签名恶意固件。所以公钥要么烧死在OTP里,要么用证书链保护起来。

4.2 消息认证码(HMAC)—— 轻量级的完整性+认证

数字签名虽然好,但计算开销大。有些场景下,比如设备和服务端之间有共享密钥,我们不需要非对称加密,用HMAC就够了。

HMAC(基于哈希的消息认证码)本质上就是带密钥的哈希。它既能保证数据没被篡改,又能证明数据是由持有密钥的一方生成的。

4.2.1 HMAC vs 数字签名

特性 HMAC 数字签名(ECDSA)
密钥类型 对称密钥(共享密钥) 非对称密钥(私钥/公钥)
计算开销 低(仅哈希运算) 高(椭圆曲线运算)
密钥分发 需要安全通道 公钥可公开
不可否认性 不支持 支持
典型应用 固件包完整性校验、会话认证 固件签名、代码签名

你可能会问:什么时候用HMAC,什么时候用数字签名?

我的经验是:如果设备和服务端之间有预置的共享密钥,且不需要第三方验证,HMAC就够用了。比如设备从云端下载固件增量包,用HMAC校验完整性,速度快、代码量小。但如果固件要分发给多个设备,或者需要审计追溯,那就得上数字签名。

4.2.2 HMAC在固件升级中的应用

我参与过一个智能家居项目,设备通过BLE接收固件包。因为BLE带宽小、传输慢,我们用了HMAC-SHA256来校验每个数据块。

// 伪代码:固件块HMAC校验
uint8_t block_data[1024];
uint8_t block_hmac[32];
uint8_t shared_key[16];

// 计算当前块的HMAC
hmac_sha256(shared_key, sizeof(shared_key),
            block_data, sizeof(block_data),
            block_hmac);

// 比对接收到的HMAC
if (memcmp(block_hmac, received_hmac, 32) == 0) {
    // 块校验通过,写入Flash
    write_flash(block_data, block_offset);
} else {
    // 块校验失败,请求重传
    request_retransmit(block_index);
}

小技巧:HMAC的密钥不要硬编码在代码里。我习惯的做法是:设备出厂时注入一个根密钥,然后通过KDF派生出HMAC密钥。这样即使某个密钥泄露,也不会影响整个产品线。

4.3 密钥派生函数(KDF)—— 从一根密钥衍生出无数密钥

说到密钥派生,正好接上刚才的话题。KDF(Key Derivation Function)解决的是密钥管理的问题。

你想想看,一个设备可能同时需要:固件加密密钥、HMAC密钥、TLS会话密钥、本地存储加密密钥……如果每个密钥都单独存储,Flash根本不够用,管理也乱套。

KDF的思路很简单:用一个主密钥,通过算法派生出多个子密钥。派生过程是确定性的——同样的输入,永远得到同样的输出。

4.3.1 常用的KDF算法

算法 特点 适用场景
HKDF (RFC 5869) 基于HMAC,可扩展,标准化 嵌入式首选,TLS 1.3使用
PBKDF2 带迭代次数,抗暴力破解 密码派生,不推荐用于密钥派生
SP 800-108 (NIST) 基于分组密码或哈希 政府/军工项目

我个人强烈推荐HKDF。它设计简洁、安全性经过充分验证,而且代码量很小,非常适合嵌入式环境。

4.3.2 HKDF的使用方式

HKDF分两步:提取(Extract)和扩展(Expand)。

// 伪代码:HKDF派生固件加密密钥和HMAC密钥
uint8_t master_key[32];   // 主密钥(设备出厂注入)
uint8_t salt[16];         // 随机盐值(可公开)
uint8_t fw_enc_key[16];   // 派生出的固件加密密钥
uint8_t fw_hmac_key[16];  // 派生出的HMAC密钥

// Step 1: Extract — 用盐值加固主密钥
uint8_t prk[32];
hkdf_extract(master_key, sizeof(master_key),
             salt, sizeof(salt),
             prk);

// Step 2: Expand — 派生多个子密钥
hkdf_expand(prk, sizeof(prk),
            "firmware-enc-key", 16,   // 上下文标签
            fw_enc_key, sizeof(fw_enc_key));

hkdf_expand(prk, sizeof(prk),
            "firmware-hmac-key", 17,
            fw_hmac_key, sizeof(fw_hmac_key));

核心思想:派生时使用不同的「上下文标签」(比如"firmware-enc-key"和"firmware-hmac-key"),确保不同用途的密钥在数学上相互独立。即使固件加密密钥泄露,HMAC密钥也不会受影响。

4.4 固件完整性验证 —— 把前面所有技术串起来

好了,现在我们把数字签名、HMAC、KDF组合起来,看看一个完整的固件升级安全流程长什么样。

4.4.1 完整验证流程

下面这张图展示了固件从发布到安装的完整安全链路:

固件完整性验证流程 固件完整性验证流程 ① 固件发布 ② 计算固件哈希 ③ ECDSA签名 ④ 设备接收固件 ⑤ ECDSA验签 ⑥ 验证通过 → 升级 服务端(安全环境) 私钥签名,公钥公开 哈希+签名一起打包 固件包 = 固件 + 签名 设备端(非安全环境) 公钥预置(OTP/证书) 验签通过才允许升级 验签失败 → 拒绝 + 告警

4.4.2 实际项目中的避坑指南

流程看起来简单,但落地时坑不少。我把自己踩过的和见过的坑列出来:

  • 公钥存储位置:不要放在可读写的Flash区域。我见过有人把公钥放在固件镜像里,结果升级时公钥被一起替换了。正确做法是烧死在OTP或eFuse里。
  • 签名时机:签名一定要在固件编译完成后、打包之前做。有人先打包再签名,结果签名对象包含了元数据,验签时元数据对不上就失败了。
  • 哈希算法选择:不要用MD5或SHA-1。2023年了,至少用SHA-256。我手头还有项目在用SHA-1,每次安全审计都被打回来。
  • 防回滚:签名验证通过后,还要检查固件版本号。否则攻击者可以用一个旧版本(有已知漏洞)的合法固件来降级攻击。

重要提醒:签名和验签的代码本身也要做安全防护。我曾经在调试阶段把验签结果打印到串口日志里,结果被测试工具抓到了——攻击者可以直接跳过验签逻辑。生产固件一定要关掉所有调试输出。

4.4.3 性能考量

ECDSA验签在MCU上大概需要几十到几百毫秒,具体取决于芯片有没有硬件加速器。我测试过几款主流芯片:

芯片型号 内核 ECDSA P-256验签时间 硬件加速
STM32F407 Cortex-M4 @168MHz ~180ms 无
STM32H743 Cortex-M7 @480MHz ~45ms 有(CRYP)
NXP i.MX RT1060 Cortex-M7 @600MHz ~30ms 有(DCP)
ESP32 Xtensa LX6 @240MHz ~120ms 有(硬件加速)

如果验签时间太长,可以考虑分块校验:只对固件的关键部分(比如引导加载程序)做签名验证,其余部分用HMAC校验。这样既保证了安全,又提升了启动速度。

我的建议:在项目初期就把安全验证流程设计好,不要等到开发后期再补。我见过太多项目因为「先跑起来再说」,结果安全模块被塞进一个角落里,最后要么性能不达标,要么安全漏洞一堆。