4、签名算法详解(RSA):从原理到联调实战

RSA 是固件签名领域的老大哥。虽然 ECC 这些年势头很猛,但 RSA 的生态太成熟了,工具链、库、硬件加速器几乎全覆盖。我个人习惯在 Legacy 平台或客户明确要求 RSA 时,首选它。这一章我们把它掰开揉碎,讲清楚原理、密钥长度、填充方案和签名验签流程。

核心要点:RSA 签名不是「加密」,而是用私钥对摘要做数学变换。验签是用公钥还原摘要并比对。理解这个本质,后面联调才不会跑偏。

4.1 RSA 算法原理——一句话讲透

RSA 的安全性建立在「大整数分解」的数学难题上。给你两个大素数 p 和 q,乘起来得到 n 很容易;但给你 n,反推出 p 和 q 几乎不可能。

签名过程说白了就是:

  • 私钥签名:s = m^d mod n(m 是填充后的摘要,d 是私钥指数)
  • 公钥验签:m' = s^e mod n(e 是公钥指数,m' 应与原始 m 一致)

嗯,这里要注意:RSA 签名用的是私钥,验签用的是公钥。和加密正好反过来。我在项目中遇到过新手把签名和加密搞混,结果联调时怎么都对不上——后来发现是 API 调用反了。

我的习惯:在代码里把签名函数命名为 rsa_sign_with_privkey(),验签命名为 rsa_verify_with_pubkey(),从命名上杜绝混淆。

4.2 密钥长度选择:2048 还是 4096?

这是每次做方案选型都要吵一轮的问题。我直接给结论:

密钥长度 安全等级 签名耗时(典型 MCU) 签名体积 推荐场景
2048 位 ≈ 112 位对称密钥 50~200 ms 256 字节 IoT 设备、资源受限 MCU
4096 位 ≈ 128 位对称密钥 200~800 ms 512 字节 网关、服务器、高安全要求场景

我个人建议:绝大多数固件签名场景,2048 位足够了。 为什么?

  • 签名耗时在 MCU 上已经是个瓶颈。4096 位的签名操作,在一些低主频芯片上能卡到 1 秒以上,用户升级时体验很差。
  • 签名体积翻倍。固件包本身可能只有几百 KB,签名占 512 字节倒不算大,但 OTA 传输和存储都要考虑。
  • 安全边际:2048 位在 2030 年之前被攻破的概率极低。除非你的产品要服役 20 年,或者客户是军方/金融级,否则没必要上 4096。

我曾经踩过的坑:有个项目选了 4096 位密钥,结果目标芯片的 RSA 硬件加速器只支持 2048 位。软件模拟 4096 位签名,一次要 3 秒多,最后不得不换芯片。所以选密钥长度前,一定先查芯片的 RSA 引擎规格。

4.3 填充方案:PKCS#1 v1.5 vs PSS

RSA 签名不能直接对原始摘要做模幂运算,必须先填充。填充方案决定了安全性和兼容性。

4.3.1 PKCS#1 v1.5——老牌选手

这是最经典的填充方案,格式固定:

EM = 0x00 || 0x01 || PS || 0x00 || DigestInfo
  • PS:填充字节,全是 0xFF,长度补齐到密钥长度
  • DigestInfo:哈希算法 OID + 摘要值

优点:几乎所有 RSA 库都支持,兼容性最好。缺点:存在已知的理论攻击(Bleichenbacher 攻击),虽然实际利用条件苛刻,但安全审计时会被揪出来。

4.3.2 PSS——现代推荐

PSS(Probabilistic Signature Scheme)是更安全的替代方案。它引入了随机盐值(salt),每次签名结果都不一样。

EM = maskedDB || H || 0xBC
其中 maskedDB 包含盐值和哈希值,经过掩码函数处理

PSS 有严格的安全证明,没有已知的有效攻击。我现在的项目,只要库支持,一律用 PSS。

对比项 PKCS#1 v1.5 PSS
安全性 有理论攻击 安全证明完备
签名确定性 确定(相同输入相同输出) 概率性(每次不同)
兼容性 极好 较好(需库支持)
推荐场景 Legacy 系统、互操作性要求高 新设计、安全敏感场景

我的选择策略:如果是和第三方平台联调,先问对方支持哪种。如果对方说「都行」,我选 PSS。如果对方只支持 v1.5,那就用 v1.5,但会在设计文档里注明安全风险。

4.4 签名与验签流程——手把手拆解

下面这张图把整个流程串起来了。你想想看,从固件二进制到签名文件,再到设备端验签,每一步都有明确的输入输出。

RSA 签名与验签完整流程 签名端(固件发布方) 固件二进制 哈希计算(SHA-256) 摘要(32 字节) 填充(PSS/v1.5) 私钥签名(模幂) 签名值(256 字节) 打包:固件 + 签名 + 元数据 OTA 传输 / 烧录 验签端(设备端) 解析固件 + 签名 公钥验签(模幂)

4.4.1 签名端(固件发布方)步骤

  1. 计算哈希:对固件二进制做 SHA-256,得到 32 字节摘要。
  2. 填充:按选定的填充方案(PSS 或 v1.5)对摘要进行编码,补齐到密钥长度(2048 位 = 256 字节)。
  3. 私钥签名:用私钥对填充后的数据做模幂运算,得到签名值。
  4. 打包:将原始固件、签名值、签名算法标识、公钥哈希等打包成升级文件。

4.4.2 验签端(设备端)步骤

  1. 解析:从升级包中提取固件和签名值。
  2. 计算哈希:对提取出的固件做同样的 SHA-256,得到摘要。
  3. 公钥验签:用公钥对签名值做模幂运算,得到填充后的摘要;再反向解析出原始摘要。
  4. 比对:将解析出的摘要与本地计算的摘要比对。一致则验签通过,否则拒绝升级。

我曾经遇到的一个隐蔽问题:设备端验签时,公钥是从 Flash 某个固定偏移读取的。有一次产线烧录时把公钥地址写错了,结果设备读到了一串 0xFF,验签永远失败。排查了两天才发现。所以我的建议是:公钥存储后一定要做一次自校验,比如存一份公钥的哈希,启动时先验公钥完整性。

4.5 代码示例——OpenSSL 命令行实战

联调时最常用的就是 OpenSSL 命令行。下面给出一套完整的签名和验签命令,你可以直接复制到终端里跑。

4.5.1 生成密钥对

# 生成 2048 位私钥
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out private.pem

# 从私钥导出公钥
openssl pkey -in private.pem -pubout -out public.pem

4.5.2 签名(以 PSS 为例)

# 对固件 firmware.bin 做 PSS 签名
openssl dgst -sha256 -sigopt rsa_padding_mode:pss \
  -sigopt rsa_pss_saltlen:32 \
  -sign private.pem -out firmware.sig firmware.bin

4.5.3 验签

# 验签
openssl dgst -sha256 -sigopt rsa_padding_mode:pss \
  -sigopt rsa_pss_saltlen:32 \
  -verify public.pem -signature firmware.sig firmware.bin

如果输出 Verified OK,说明验签通过。如果输出 Verification Failure,检查一下:

  • 公钥是否和私钥匹配?
  • 填充参数是否一致(PSS 的 saltlen 必须相同)?
  • 哈希算法是否一致?

联调小技巧:我习惯在签名时把填充参数写进一个 JSON 元数据文件,和设备端代码里的参数做交叉比对。这样联调时一旦失败,先查元数据,不用反复猜参数。

4.6 避坑指南——我踩过的三个坑

  • 坑一:哈希算法不匹配。签名端用 SHA-256,设备端代码里写死了 SHA-1。验签永远失败。后来我在固件头里加了一个字段标明哈希算法,设备端根据字段动态选择。
  • 坑二:PSS saltlen 不一致。OpenSSL 默认 saltlen 是 32,但有些库默认是 20。联调前先确认双方 saltlen 一致,或者在代码里显式指定。
  • 坑三:公钥格式问题。设备端通常需要 DER 格式的裸公钥,而 OpenSSL 默认输出 PEM。记得用 openssl pkey -in public.pem -outform DER -pubout -out public.der 转换。