4、密码学基础(下):数字签名(ECDSA)、消息认证码(HMAC)、密钥派生函数(KDF)与固件完整性验证
上一讲我们聊了哈希和对称加密,算是把地基打牢了。但光有这些,还不足以解决固件升级中的两个核心问题:谁签发的这个固件?以及固件在传输过程中有没有被篡改?
说白了,哈希只能保证数据没变,但不能证明数据是谁发的。我当年第一次做OTA升级时,就踩过这个坑——只校验了哈希,结果被中间人换了个恶意固件,哈希也跟着换了,系统照样升级成功。嗯,那次之后我才真正理解,完整性和认证性是两码事。
今天我们就来补上这块拼图:数字签名、消息认证码、密钥派生函数,以及它们怎么组合起来保护固件。
4.1 数字签名(ECDSA)—— 固件的「身份证+防伪标签」
数字签名解决的是不可否认性和来源认证。你想想看,固件从服务器下发到设备,设备怎么知道这个固件真的是官方发布的?靠的就是签名。
ECDSA(椭圆曲线数字签名算法)是目前嵌入式领域最常用的签名算法。相比RSA,它密钥更短、计算更快,特别适合资源受限的MCU。
4.1.1 签名与验签流程
整个过程分两步:
- 签名(服务端做):用私钥对固件哈希值签名,生成签名值。
- 验签(设备端做):用公钥解密签名,比对哈希值是否一致。
我习惯把私钥比作「印章」,公钥比作「印鉴样本」。印章只有官方有,印鉴样本公开给所有人。你拿到的文件上盖了章,拿印鉴样本一对,就知道是不是真的。
关键点:设备端只存公钥,不存私钥。私钥一旦泄露,整个产品线的安全就崩了。我在项目中见过有人把私钥硬编码在编译脚本里,差点被提交到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 完整验证流程
下面这张图展示了固件从发布到安装的完整安全链路:
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校验。这样既保证了安全,又提升了启动速度。
我的建议:在项目初期就把安全验证流程设计好,不要等到开发后期再补。我见过太多项目因为「先跑起来再说」,结果安全模块被塞进一个角落里,最后要么性能不达标,要么安全漏洞一堆。