3、密码学基础(二):数字签名原理详解——签名与验签的数学逻辑

上一章我们聊了哈希函数,它是数字签名的「燃料」。但光有哈希还不够——你得证明这个哈希值是你生成的,而且没人能伪造。这就是数字签名要干的事。

说白了,数字签名就是「用私钥加密哈希值,用公钥验证」。但这里有个坑:RSA 里「加密」和「签名」虽然数学上很像,但概念完全不同。我刚开始学的时候也混淆过,后来踩了一次坑才彻底搞明白。

3.1 签名与验签的核心流程

先看一张整体流程图,把签名和验签的全貌刻在脑子里:

数字签名与验签核心流程 数字签名与验签核心流程 签名方(Alice) 原始消息 M 哈希值 H(M) 私钥 SK 签名值 S 发送 (M, S) 给 Bob 验签方(Bob) 收到 (M, S) 公钥 PK H(M) 本地计算 解密 S 得 H'(M) 比较 H(M) == H'(M) ? 核心要点 • 签名 = 私钥对哈希值做「签名运算」(不是加密,但数学上类似) • 验签 = 公钥对签名值做「验签运算」,得到哈希值,再与本地哈希比较 • 哈希值相等 → 签名有效;不等 → 签名无效(消息被篡改或身份伪造) • 私钥只有签名方持有,所以任何人都无法伪造签名

流程其实就三步:

  1. 签名方:对消息 M 做哈希,得到 H(M);然后用私钥对 H(M) 做签名运算,得到签名值 S;最后把 (M, S) 一起发出去。
  2. 验签方:收到 (M, S) 后,用同样的哈希算法对 M 算一遍,得到 H(M);然后用公钥对 S 做验签运算,得到 H'(M)。
  3. 比较:如果 H(M) == H'(M),签名有效;否则无效。

关键认知:签名不是「加密消息」,而是「加密哈希值」。你想想看,如果直接加密整个消息,RSA 2048 位只能加密 256 字节,大文件根本塞不下。哈希一下,固定长度,完美解决。

3.2 RSA 签名与验签的数学逻辑

RSA 签名用的是「私钥指数 d」和「模数 n」。验签用的是「公钥指数 e」和同样的「模数 n」。数学上,签名和验签互为逆运算。

3.2.1 签名运算

假设哈希值 H 是一个整数(小于 n),签名值 S 的计算公式是:

S = H^d mod n

这里 d 是私钥指数,n 是模数。S 就是最终的签名值。

我在项目中遇到过一个问题:如果哈希值 H 大于等于 n,签名运算会出错。因为模运算要求被模数小于模数。所以 RSA 签名前必须确保哈希值被「截断」或「填充」到合适的长度。PKCS#1 v1.5 和 PSS 填充模式就是干这个的。

3.2.2 验签运算

验签方收到签名值 S 后,用公钥指数 e 和模数 n 做运算:

H' = S^e mod n

如果 H' 等于本地计算的哈希值 H,签名有效。

为什么会这样?因为 RSA 的数学性质保证了:

(H^d)^e mod n = H^(d*e) mod n = H

d 和 e 是模 φ(n) 的乘法逆元,所以 d*e ≡ 1 (mod φ(n))。这就是 RSA 签名能工作的根本原因。

我的经验:刚开始写 RSA 签名代码时,我直接用原始哈希值去算,结果发现验签老失败。后来才意识到,哈希值必须经过「填充」才能做签名运算。PKCS#1 v1.5 填充会在哈希值前面加固定字节,确保数值小于 n。这个坑我踩过,你千万别踩。

3.3 ECDSA 签名与验签的数学逻辑

ECDSA(椭圆曲线数字签名算法)和 RSA 完全不同。它基于椭圆曲线上的点群运算,不是模幂运算。我刚开始学 ECDSA 时觉得它很抽象,但用多了就发现它其实很优雅。

3.3.1 椭圆曲线基础

一条椭圆曲线方程长这样:

y² = x³ + ax + b (mod p)

其中 p 是一个大素数,a 和 b 是曲线参数。比特币用的 secp256k1 曲线,a=0, b=7。

椭圆曲线上的点构成一个群,可以做「点加法」和「标量乘法」。私钥是一个随机整数 k,公钥是 k 乘以基点 G:

公钥 = k * G

这里的乘法是标量乘法,不是普通整数乘法。从公钥反推私钥,就是「椭圆曲线离散对数问题」,目前没有多项式时间解法。

3.3.2 签名过程

ECDSA 签名生成两个值:(r, s)。流程如下:

  1. 随机生成一个临时私钥 k(每次签名都不一样)
  2. 计算临时公钥点 R = k * G,取 R 的 x 坐标作为 r
  3. 计算 s = k⁻¹ * (H + r * d) mod n,其中 d 是私钥,H 是哈希值,n 是曲线的阶
  4. 签名值就是 (r, s)

重要警告:临时私钥 k 必须每次重新生成,且绝对不能泄露。我曾经见过一个项目,因为随机数生成器有 bug,导致两次签名用了相同的 k,结果私钥被直接算出来了。这不是理论攻击,是真实发生过的事故。

3.3.3 验签过程

验签方收到 (r, s) 和消息 M 后:

  1. 计算哈希值 H = Hash(M)
  2. 计算 w = s⁻¹ mod n
  3. 计算 u1 = H * w mod n, u2 = r * w mod n
  4. 计算点 P = u1 * G + u2 * Q,其中 Q 是公钥
  5. 如果 P 的 x 坐标等于 r,签名有效;否则无效

数学上,如果签名是有效的,P 的 x 坐标一定等于 r。因为:

P = u1*G + u2*Q
  = (H*w)*G + (r*w)*(d*G)
  = (H + r*d)*w * G
  = (H + r*d) * s⁻¹ * G
  = (H + r*d) * (k⁻¹ * (H + r*d))⁻¹ * G
  = k * G
  = R

所以 P 就是 R,x 坐标自然等于 r。

3.4 RSA vs ECDSA:怎么选?

我在实际项目中两种都用过,给你一个对比表:

特性 RSA ECDSA
安全性基础 大整数分解 椭圆曲线离散对数
密钥长度(同等安全) 2048 位 256 位
签名速度 较慢(私钥操作) 较快
验签速度 较快(公钥操作) 较慢
签名大小 256 字节(2048 位) 64 字节(secp256k1)
随机数要求 不需要 需要高质量随机数
典型应用 HTTPS 证书、SSH 区块链、比特币、以太坊

我的建议:如果你做的是传统 Web 应用或证书系统,RSA 更成熟、兼容性更好。如果你做的是区块链、物联网或移动端应用,ECDSA 的短签名和短密钥优势很明显。我个人在开发签名验签工具时,两种都支持,让用户自己选。

3.5 签名填充模式:PKCS#1 v1.5 vs PSS

RSA 签名不能直接用原始哈希值,必须填充。两种主流模式:

3.5.1 PKCS#1 v1.5 签名填充

格式固定:

0x00 0x01 [0xFF...0xFF] 0x00 [DigestInfo]

其中 [0xFF...0xFF] 是填充字节,长度补齐到模数长度。[DigestInfo] 包含哈希算法标识和哈希值。

这个模式简单,但有理论上的攻击面。不过实际中只要实现正确,仍然安全。

3.5.2 PSS(Probabilistic Signature Scheme)

PSS 是更现代的模式,加了随机盐值,安全性更高:

EM = MGF1(DB) XOR maskedDB || H || 0xBC

其中 DB 包含盐值和哈希值,MGF1 是掩码生成函数。PSS 有严格的安全证明。

我的习惯:新项目我全部用 PSS。虽然实现比 PKCS#1 v1.5 复杂一点,但安全性更扎实。如果你要兼容旧系统,PKCS#1 v1.5 也行,但记得检查实现有没有漏洞。

3.6 避坑指南:签名实现中的常见错误

我这些年见过不少签名相关的 bug,列几个最常见的:

  • 哈希值没截断:RSA 签名要求哈希值小于 n,SHA-256 输出 256 位,RSA 2048 位没问题,但 RSA 1024 位就要小心了。
  • 随机数重用:ECDSA 的 k 绝对不能重用,否则私钥会被算出来。用 RFC 6979 的确定性 k 生成可以避免这个问题。
  • 签名值编码错误:ECDSA 的 (r, s) 是整数,但传输时要用 DER 编码或固定长度编码。我见过因为编码不一致导致验签失败的情况。
  • 公钥未验证:验签前要检查公钥是否在曲线上。如果公钥是攻击者伪造的,验签结果不可信。

我曾经:在一个项目中,用 OpenSSL 的 RSA_sign 函数时,忘了检查返回值。结果有一次私钥文件损坏,签名函数返回了错误,但代码没处理,直接用了未初始化的签名缓冲区。嗯,那个 bug 查了我整整两天。所以,永远检查签名函数的返回值。

3.7 小结

数字签名的核心就三句话:

  • 签名 = 私钥对哈希值做运算,生成固定长度的签名值
  • 验签 = 公钥对签名值做逆运算,比较哈希值是否一致
  • RSA 基于大整数分解,ECDSA 基于椭圆曲线离散对数