跳转至

数字签名

本文你会学到

  • 为什么加密能保护机密性,但无法证明"这条消息确实是你发的"
  • 数字签名如何像"手写签名"一样同时提供身份认证和不可否认性
  • 签名安全强度的"木桶短板"原则——摘要算法和公钥算法如何互相制约
  • Java Signature 类的标准使用模式(初始化→更新→签名/验签)
  • Hash-and-Sign 范式为什么必须先哈希再签名,以及 FDH 方案的理论意义
  • DSA、ECDSA、EdDSA、RSA 签名、SM2 国密签名各自的原理和 Java API 用法
  • 在实际项目中如何选择合适的签名算法

🤔 为什么需要数字签名?

你已经知道,对称加密解决了**机密性**问题,HMAC 解决了**完整性 + 认证**问题。但 HMAC 有一个根本限制——通信双方必须共享同一个密钥。如果你收到一条消息,HMAC 能告诉你"这条消息确实来自持有密钥的人",但它无法帮你向第三方证明"这条消息来自 Alice 而不是 Bob"——因为 Alice 和 Bob 共享同一个密钥,谁都能生成有效的 HMAC。

更关键的问题是**不可否认性(non-repudiation)**:Alice 签了一份电子合同后声称"我没签过",你怎么证明?HMAC 做不到——因为密钥是共享的,你无法排除 Bob 伪造的可能性。

💡 想象现实中的手写签名:你的笔迹别人难以模仿,所以签名可以作为身份证明。数字签名在密码学世界里扮演同样的角色——只有持有**私钥**的人才能生成签名,而任何人都可以用**公钥**验证签名的真实性。

数字签名的安全模型:EUF-CMA

与 MAC 类似,数字签名的安全性也用 EUF-CMA(Existential Unforgeability under Chosen Message Attack)来定义。区别在于:MAC 使用共享密钥,数字签名使用公私钥对。

攻击游戏:

  1. 挑战者生成密钥对 \((pk, sk)\),将 \(pk\) 发送给攻击者
  2. 攻击者可以请求任意消息的签名(chosen message attack)
  3. 攻击者输出一个新消息和对应的签名——如果用 \(pk\) 验证通过则攻击者获胜

数字签名方案被定义为三元组 \(\mathcal{S} = (\text{G}, \text{S}, \text{V})\)

  • 密钥生成 G:输出 \((pk, sk)\)
  • 签名 S:用私钥 \(sk\) 对消息签名
  • 验证 V:用公钥 \(pk\) 验证签名

💡 Java Signature 类的 initSign()/update()/sign() 对应 \(\text{S}\)initVerify()/verify() 对应 \(\text{V}\),而密钥对由 KeyPairGenerator 生成(对应 \(\text{G}\))。

数字签名提供三个核心保证:

  • 身份认证(authentication):只有私钥持有者能生成有效签名
  • 完整性(integrity):消息被篡改后签名验证会失败
  • 不可否认性(non-repudiation):签名者无法否认自己签署过这条消息
sequenceDiagram
    participant Alice as Alice(签名者)
    participant Bob as Bob(验证者)
    participant Eve as Eve(攻击者)

    Note over Alice: 持有私钥,生成签名
    Alice->>Bob: 消息 + 数字签名
    Note over Bob: 持有公钥,验证签名
    Bob->>Bob: 验证签名通过 ✅

    Note over Eve: 截获消息和签名
    Eve->>Bob: 篡改消息 + 原始签名
    Bob->>Bob: 验证签名失败 ❌

    Eve->>Bob: 原始消息 + 伪造签名
    Bob->>Bob: 验证签名失败 ❌(没有私钥)

数字签名分为两大类:

  • 确定性签名(deterministic):同一私钥对同一消息,每次产生的签名完全相同(如 RSA PKCS#1 v1.5)
  • 非确定性签名(non-deterministic):每次签名都会引入随机数,同一消息的签名每次不同(如 DSA、ECDSA)

🛡️ 签名安全强度

当你需要选择签名算法的密钥长度和摘要算法时,你会发现一个容易被忽视的问题:签名的安全强度由多个组件共同决定,其中最弱的那个组件决定了整体安全性。

💡 把签名想象成一个链条——摘要算法、公钥算法、密钥长度是链条上的每个环节。链条的强度取决于最薄弱的环节,而不是最强的那个。

强不可伪造性

EUF-CMA 只要求攻击者不能为**新消息**伪造签名。但更严格的安全定义还要求:即使在**已签名的消息**上,攻击者也不能生成新的有效签名。

两种额外攻击需要防御:

  • Message Confusion:攻击者找到一个新消息 \(m'\) 使得已有的签名 \(\sigma\) 也是 \(m'\) 的合法签名
  • Signer Confusion:攻击者找到另一对公私钥 \((pk', sk')\) 使得 \(\sigma\)\(pk'\) 下对 \(m\) 的合法签名

💡 SHA-256withRSA 的 "with" 语法确保哈希算法是固定的,防止消息混淆攻击——验证者知道应该用 SHA-256 计算摘要,攻击者无法利用不同哈希算法的结果来构造合法签名。

NIST SP 800-57 定义了不同算法的安全强度:

摘要算法安全强度

摘要算法 安全强度(位)
SHA-1 ≤ 80
SHA-224, SHA-512/224, SHA3-224 112
SHA-256, SHA-512/256, SHA3-256 128
SHA-384, SHA3-384 192
SHA-512, SHA3-512 256

公钥算法安全强度

算法 密钥长度 安全强度(位)
DSA / RSA 1024 ≤ 80
DSA / RSA 2048 112
DSA / RSA 3072 128
ECDSA 160-223 ≤ 80
ECDSA 224-255 112
ECDSA 256-383 128
ECDSA 384-511 192
ECDSA 512+ 256

木桶短板原则

选择签名组件时,遵循一条通用规则:摘要算法的安全强度应不低于公钥算法。同样,如果签名方案涉及掩码生成函数(MGF),MGF 中使用的摘要强度也不应低于签名摘要的强度。

推荐组合 公钥算法 密钥长度 摘要算法 安全强度
入门级 RSA 2048 SHA-256 112
推荐级 RSA 3072 SHA-256 128
推荐级 ECDSA P-256 SHA-256 128
高安全 ECDSA P-384 SHA-384 192
极高安全 RSA 15360 SHA-512 256

⚠️ 不要用 SHA-1 生成新签名。虽然验证旧的 SHA-1 签名在大多数场景下仍然可行,但生成新的 SHA-1 签名已被 FIPS 禁止,存在安全隐患。

☕ Signature 类

JCA 中,签名操作统一由 java.security.Signature 类提供。和 JCA 的其他引擎类一样,Signature 对象通过 getInstance() 工厂方法创建,而不是直接构造。

算法命名规范

签名算法的标准命名格式为:

<摘要算法>with<公钥算法>

例如:

算法名称 含义
SHA256withDSA DSA 签名 + SHA-256 摘要
SHA256withECDSA ECDSA 签名 + SHA-256 摘要
SHA256withRSA RSA PKCS#1 v1.5 签名 + SHA-256 摘要
SHA256withRSAandMGF1 RSA-PSS 签名 + SHA-256 摘要
EdDSA EdDSA 签名(摘要算法由密钥类型决定)
SM3withSM2 SM2 签名 + SM3 摘要

标准使用模式

无论哪种签名算法,Signature 的使用都遵循三个步骤:

graph LR
    A[1. 初始化<br/>initSign / initVerify] --> B[2. 传入数据<br/>update]
    B --> C["3. 签名 / 验证<br/>sign / verify"]

签名生成(私钥操作):

Signature 类签名流程
1
2
3
4
5
6
7
8
9
// 1. 初始化——传入私钥
Signature signature = Signature.getInstance("SHA256withECDSA", "BC");
signature.initSign(privateKey);

// 2. 传入待签名的数据
signature.update(data);

// 3. 生成签名
byte[] signatureBytes = signature.sign();

签名验证(公钥操作):

Signature 类验证流程
1
2
3
4
5
6
7
8
9
// 1. 初始化——传入公钥
Signature signature = Signature.getInstance("SHA256withECDSA", "BC");
signature.initVerify(publicKey);

// 2. 传入待验证的数据
signature.update(data);

// 3. 验证签名
boolean isValid = signature.verify(signatureBytes);

部分签名算法还需要调用 setParameter() 设置额外参数(如 RSA-PSS 的盐值长度、SM2 的用户 ID),这些将在对应章节中介绍。

🔐 DSA(Digital Signature Algorithm)

DSA(Digital Signature Algorithm,数字签名算法)诞生于 1991 年,是 NIST 发布的第一个数字签名标准(FIPS PUB 186),至今仍是许多安全标准的基石。

离散对数问题

当你需要对一段数据"签名"时,你面临一个核心挑战:如何在公开公钥的前提下,让签名只与私钥相关?DSA 的答案来自**离散对数问题(Discrete Logarithm Problem,DLP)**。

💡 想象你有一个大质数 P,以及一个生成元 G。已知 Y = G^X mod P 和公钥 Y,要推导出私钥 X 在计算上是不可行的——这就是离散对数问题。DSA 的安全性正是建立在这个难题之上。

DSA 参数:P、Q、G

DSA 密钥基于一组公开参数 (P, Q, G):

  • P:大质数,位长从 {1024, 2048, 3072} 中选择
  • Q:P - 1 的质因数,位长从 {160, 224, 256} 中选择(取决于 P 的位长)
  • G:GF(P) 乘法群中阶为 Q 的生成元,满足 1 < G < P

在 Java 中,这些参数通过 java.security.spec.DSAParameterSpec 携带。参数可以通过 AlgorithmParameterGenerator 生成,也可以在密钥生成时自动创建。

密钥生成

DSA 密钥对包含一个私钥 X 和公钥 Y:

  • X:随机生成,满足 0 < X < Q
  • Y = G^X mod P
生成 2048 位 DSA 密钥对
1
2
3
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("DSA", "BC");
keyPairGenerator.initialize(2048); // 自动生成合适的 P/Q/G 参数
KeyPair keyPair = keyPairGenerator.generateKeyPair();

注意:生成 DSA 参数(P/Q/G)是一个较慢的操作。如果性能敏感,可以预先生成参数并复用。

签名流程

DSA 签名由两个整数 R 和 S 组成,计算步骤如下:

  1. 生成随机数 K,满足 0 < K < Q
  2. 计算 R = (G^K mod P) mod Q
  3. 计算 S = K^(-1) * (H(m) + X*R) mod Q

其中 H(m) 是消息 m 的哈希值。

graph TD
    A["生成随机数 K<br/>0 < K < Q"] --> B["计算 R = (G^K mod P) mod Q"]
    B --> C["计算 S = K⁻¹ × (H(m) + X×R) mod Q"]
    C --> D["签名 = (R, S)"]

    style A fill:transparent,stroke:#f57c00,color:#adbac7
    style B fill:transparent,stroke:#0288d1,color:#adbac7
    style C fill:transparent,stroke:#0288d1,color:#adbac7
    style D fill:transparent,stroke:#388e3c,color:#adbac7

⚠️ 随机数 K 的安全性至关重要:如果 K 被泄露或不够随机,攻击者可以直接推导出私钥 X。历史上著名的索尼 PS3 破解事件就是因为 K 值没有随机化。如果你无法确保良好的随机数生成器,应该使用确定性 DSA(RFC 6979)。

Java 代码示例

DSA 签名与验证
// 完整示例见 DsaTest.java
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("DSA", "BC");
keyPairGenerator.initialize(2048);
KeyPair keyPair = keyPairGenerator.generateKeyPair();

byte[] messageBytes = "DSA 签名测试消息".getBytes();

// 用私钥签名
Signature signature = Signature.getInstance("SHA256withDSA", "BC");
signature.initSign(keyPair.getPrivate());
signature.update(messageBytes);
byte[] signatureBytes = signature.sign();

// 用公钥验证
signature.initVerify(keyPair.getPublic());
signature.update(messageBytes);
boolean isValid = signature.verify(signatureBytes); // true

signature.sign() 返回的字节数组是 R 和 S 两个整数的 ASN.1 DER 编码(SEQUENCE 包含两个 INTEGER)。由于 ASN.1 INTEGER 是有符号的,签名长度可能有 1 字节的差异。

确定性 DSA(RFC 6979)

当 DSA 的随机数 K 不够安全或不可用时,RFC 6979 提供了一种确定性方案——从私钥和消息哈希中计算出 K,而非依赖随机数生成器。这样同一私钥对同一消息总是产生相同的签名,同时 K 的安全性等同于私钥。

在 Bouncy Castle 中,确定性 DSA 的算法名称为 DDSA(普通 DSA)和 ECDDSA(椭圆曲线 DSA)。注意:确定性签名可以由标准 DSA/ECDSA 验证器验证,无需特殊处理。

📈 ECDSA(Elliptic Curve DSA)

当你需要与 DSA 相同安全强度但更短的密钥时,你会发现在椭圆曲线上实现 DSA——即 ECDSA(Elliptic Curve DSA,椭圆曲线数字签名算法)——是一个更好的选择。

为什么用椭圆曲线?

传统 DSA 使用大整数域,2048 位密钥提供 112 位安全强度。ECDSA 使用椭圆曲线数学,256 位密钥就能达到 128 位安全强度——密钥更短,计算更快,签名更小。

💡 把传统 DSA 想象成在数论世界里做乘方运算(大数域),把 ECDSA 想象成在几何曲线上做"加法"运算(椭圆曲线域)。两者的安全基础都是"离散对数问题",但椭圆曲线的离散对数问题更难解,因此可以用更短的密钥达到同等安全强度。

曲线命名

ECDSA 定义在两种有限域上:

  • GF(p):基于大质数的域,FIPS 曲线以 P- 开头(如 P-256、P-384)
  • GF(2^m):基于不可约多项式的域,以 B-K-(Koblitz 曲线)开头

常用的曲线及其别名:

FIPS 名称 SEC 名称 密钥长度 安全强度
P-256 secp256r1 / prime256v1 256 位 128 位
P-384 secp384r1 384 位 192 位
P-521 secp521r1 521 位 256 位

密钥生成与签名

ECDSA 的密钥生成和签名流程与传统 DSA 类似,只是数学运算从大整数域换到了椭圆曲线域:

  • 私钥 d:随机生成,满足 0 < d < N(N 是曲线基点 G 的阶)
  • 公钥 Q = d × G(椭圆曲线点乘)

签名步骤:

  1. 生成随机数 K,满足 0 < K < N
  2. 计算 (x, y) = K × G
  3. R = x mod N(如果 R == 0,重新生成 K)
  4. S = K^(-1) × (H(m) + d × R) mod N
ECDSA 签名与验证(secp256r1 曲线)
// 完整示例见 DsaTest.java
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("ECDSA", "BC");
keyPairGenerator.initialize(new ECGenParameterSpec("secp256r1"));
KeyPair keyPair = keyPairGenerator.generateKeyPair();

byte[] messageBytes = "ECDSA 签名测试消息".getBytes();

// 用私钥签名
Signature signature = Signature.getInstance("SHA256withECDSA", "BC");
signature.initSign(keyPair.getPrivate());
signature.update(messageBytes);
byte[] signatureBytes = signature.sign();

// 用公钥验证
signature.initVerify(keyPair.getPublic());
signature.update(messageBytes);
assertTrue(signature.verify(signatureBytes));

// ECDSA 每次签名都不同(因为随机数 K 不同)
Signature sig2 = Signature.getInstance("SHA256withECDSA", "BC");
sig2.initSign(keyPair.getPrivate());
sig2.update(messageBytes);
byte[] signatureBytes2 = sig2.sign();
// signatureBytes ≠ signatureBytes2

DSA vs ECDSA 对比

特性 DSA ECDSA
数学基础 大整数离散对数问题 椭圆曲线离散对数问题
128 位安全所需密钥长度 3072 位 256 位
签名大小 ~56 字节(DER 编码) ~72 字节(DER 编码)
签名确定性 非确定性(随机 K) 非确定性(随机 K)
确定性变体 DDSA(RFC 6979) ECDDSA(RFC 6979)
参数生成速度 慢(大质数搜索) 快(使用预定义曲线)
算法名称 SHA256withDSA SHA256withECDSA

⚡ EdDSA(Edwards Curve DSA)

当你需要一种**天然确定性**且**抗侧信道攻击**的签名方案时,传统 DSA/ECDSA 的随机数依赖就成了一个负担。EdDSA(Edwards Curve Digital Signature Algorithm)通过巧妙的数学设计彻底消除了这个问题。

什么是 Edwards 曲线?

Edwards 曲线是椭圆曲线的一种特殊形式,具有数学上更"整齐"的运算性质。EdDSA 使用的具体曲线有两种:

曲线 安全强度 内置摘要 签名长度
Ed25519 ~128 位 SHA-512 64 字节(R: 32 + S: 32)
Ed448 ~224 位 SHAKE-256 114 字节(R: 57 + S: 57)

EdDSA 的三大优势

  1. 确定性签名:不依赖随机数生成器,同一私钥对同一消息总是产生相同签名。K 值从私钥和消息中确定性推导
  2. 抗侧信道攻击:Edwards 曲线的运算设计天然抵抗时序攻击和功耗分析攻击
  3. 签名恒定长度:Ed25519 签名固定 64 字节,Ed448 固定 114 字节,不会像 DSA 那样因 ASN.1 编码出现 1 字节的差异

Java 代码示例

EdDSA 的使用非常简洁——算法名称统一为 EdDSA,具体使用哪条曲线由密钥类型决定:

Ed25519 签名与验证
// 完整示例见 DsaTest.java
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("Ed25519", "BC");
KeyPair keyPair = keyPairGenerator.generateKeyPair();

byte[] messageBytes = "Ed25519 签名测试消息".getBytes();

// 用私钥签名(EdDSA 内置 SHA-512 哈希,无需指定摘要算法)
Signature signature = Signature.getInstance("EdDSA", "BC");
signature.initSign(keyPair.getPrivate());
signature.update(messageBytes);
byte[] signatureBytes = signature.sign(); // 固定 64 字节

// 用公钥验证
signature.initVerify(keyPair.getPublic());
signature.update(messageBytes);
assertTrue(signature.verify(signatureBytes));

// 确定性:同一消息 + 同一私钥 = 同一签名
Signature sig2 = Signature.getInstance("EdDSA", "BC");
sig2.initSign(keyPair.getPrivate());
sig2.update(messageBytes);
byte[] signatureBytes2 = sig2.sign();
// signatureBytes == signatureBytes2 ✅

如果需要锁定到特定曲线,也可以直接使用 Ed25519Ed448 作为算法名称。

⚠️ Ed25519 有多种变体(纯 Ed25519、带 context 的 Ed25519ctx、带预哈希的 Ed25519ph),不同变体的签名不能交叉验证。在 Java 中使用 Bouncy Castle 时,EdDSA 算法名称默认使用纯 Ed25519。

Ed25519 vs ECDSA:现代签名的事实标准

你已经知道 ECDSAEd25519 都是椭圆曲线签名方案,但选哪个?答案几乎总是:新项目用 Ed25519。理由藏在它们核心设计的最大差异里。

随机 nonce:ECDSA 的阿喀琉斯之踵

ECDSA 签名需要一个每次都不同的随机数 k(也叫 nonce)。如果 k 被重复使用或可预测,攻击者可以直接推导出私钥——只需两个共享 k 的签名就足够了。

💡 这不是理论威胁。2010 年,索尼 PlayStation 3 的固件签名系统因为将 k 硬编码为常量,导致攻击者提取了私钥,PS3 随后被完全破解。ECDSA 是一把"如果 k 安全则无懈可击,如果 k 出问题则彻底崩溃"的算法。

graph TD
    A["ECDSA 签名<br/>需要随机 k"] --> B{k 是否安全?}
    B -- "k 重复或可预测" --> C["攻击者可推导私钥 ❌"]
    B -- "k 真随机且唯一" --> D["签名安全 ✅"]

    style A fill:transparent,stroke:#0288d1,color:#adbac7
    style B fill:transparent,stroke:#f57c00,color:#adbac7
    style C fill:transparent,stroke:#d32f2f,color:#adbac7
    style D fill:transparent,stroke:#388e3c,color:#adbac7

Ed25519 的确定性设计

Ed25519 从根本上消除了这个问题:nonce 不再是随机数,而是从私钥和消息共同哈希**确定性推导**得出。即使在随机数生成器质量不佳的嵌入式环境中,Ed25519 依然安全。

维度 ECDSA(P-256) Ed25519
nonce 来源 随机数生成器(每次不同) 确定性推导(私钥 + 消息)
nonce 重用风险 私钥泄露(致命) 无风险(nonce 无法重用)
签名大小 ~72 字节(DER 编码) 64 字节(固定)
侧信道抗性 一般(需额外防护) 优秀(曲线运算天然常量时间)
安全基础 无正式安全证明 基于 Schnorr(有安全归约)
兼容性 极广泛(TLS、JWT、代码签名) 越来越广泛(SSH、TLS 1.3、JWT)

📌 Ed25519 本质上是 Schnorr 签名在 Edwards25519 曲线上的实例化,而 ECDSA 是为绕过 Schnorr 专利(2008 年到期)而设计的"扭曲变体"。专利到期后,EdDSA 终于可以直接继承 Schnorr 的所有优良性质。

🔧 Hash-and-Sign 范式与 FDH 签名方案

为什么必须先哈希再签名?

考虑一个"裸签名"方案:签名 = \(m^d \bmod n\)。这种方案有一个致命缺陷:任何人都可以伪造签名,不需要知道私钥。攻击者只需选择一个随机签名 \(\sigma\),然后计算 \(m = \sigma^e \bmod n\)——\(m\) 就是用公钥 \(e\) 验证通过的"消息"。

这就是**Hash-and-Sign 范式**被发明的原因:

\[\text{S}'(sk, m) = \text{S}(sk, H(m))\]

先用哈希函数将任意长消息压缩为固定长度的摘要,再对摘要签名。安全性依赖碰撞抗性:如果 \(H\) 是碰撞安全的且 \(\text{S}\) 是安全的,则 \(\text{S}'\) 也是安全的。

FDH:Full Domain Hash

FDH(Full Domain Hash) 是 Hash-and-Sign 范式的理论实例化:签名 = \(H(m)^{-1} \bmod n\)(在陷门置换下求逆)。\(H\) 被视为随机预言(random oracle),将消息映射到陷门置换的定义域内。

FDH 在随机预言模型下的安全归约为:

\[\text{SIGadv} \leq (Q_\text{ro} + 1) \cdot \text{OWadv}\]

其中 \(Q_\text{ro}\) 是哈希查询次数,\(\text{OWadv}\) 是底层的单向函数安全优势。RSA-FDH 利用 RSA 的随机自归约性获得了更紧的界:\(\text{SIGadv} \leq 2.72 \cdot (Q_s + 1) \cdot \text{RSAadv}\)

⚠️ 无哈希的 RSA 签名还面临**盲签名攻击**:攻击者选择随机数 \(r\),计算 \(m' = m \cdot r^e \bmod n\) 让签名者签名,得到 \(\sigma' = (m')^d \bmod n = \sigma \cdot r \bmod n\),再除以 \(r\) 就得到 \(m\) 的合法签名——签名者完全不知道自己签了什么消息。这就是为什么 RSA 签名必须使用填充方案(PKCS#1 v1.5 或 PSS),而不是直接对消息求逆。

💡 SHA256withRSA 就是 Hash-and-Sign 的实例化。RSA-PSS 比 PKCS#1 v1.5 更接近 FDH 的理论安全保证,这也是 NIST 推荐 RSA-PSS 的原因之一。

Schnorr 签名与聚合签名(MuSig)

在「EdDSA(Edwards Curve DSA)」中你已经遇到了 Schnorr 的身影——EdDSA 正是基于它构建的。Schnorr 本身有一个 ECDSA 无法做到的独特性质:线性性(linearity),这使得多方签名聚合成为可能。

Schnorr 的线性性质

Schnorr 签名在数学上满足"加法可交换":多个签名者各自生成的签名分量可以直接相加,合并成一个与单个签名**大小完全相同**的聚合签名。验证时,聚合公钥也可以同样相加。这个性质被称为**签名聚合(Signature Aggregation)**。

graph LR
    A["签名者 Alice<br/>(R_A, s_A)"] --> D
    B["签名者 Bob<br/>(R_B, s_B)"] --> D
    C["签名者 Carol<br/>(R_C, s_C)"] --> D
    D["聚合签名<br/>(R_A+R_B+R_C, s_A+s_B+s_C)"] --> E["验证者<br/>一次验证 ✅"]

    style A fill:transparent,stroke:#0288d1,color:#adbac7
    style B fill:transparent,stroke:#0288d1,color:#adbac7
    style C fill:transparent,stroke:#0288d1,color:#adbac7
    style D fill:transparent,stroke:#388e3c,color:#adbac7
    style E fill:transparent,stroke:#7b1fa2,color:#adbac7

MuSig:安全的多方聚合协议

直接将 Schnorr 签名相加存在安全问题:恶意签名者可以声称自己的公钥为 pk_evil = pk_evil_raw - pk_alice,从而"抵消"其他人的公钥,独自控制聚合密钥——这被称为 rogue-key attack(流氓密钥攻击)

MuSig 协议(2018)通过**承诺-挑战-响应**的多轮交互解决了这个问题:

  1. 每位签名者先承诺(commit)自己的 nonce 值
  2. 汇总所有承诺后,各自公开 nonce
  3. 基于所有人的 nonce 和公钥计算挑战,产生可聚合的签名分量

MuSig2(2021)将交互轮次从 3 轮降至 2 轮,更适合实际部署。

📌 比特币 Taproot(2021 年激活)采用了 Schnorr 签名(基于 secp256k1 曲线),核心原因正是聚合能力:多签名合约在链上的占用空间与单签名相同,既节省手续费,又增强了隐私性。ECDSA 不具备线性性质,无法直接聚合——每个签名者的签名必须单独验证。

零知识证明入口:签名怎样证明而不泄露

理解数字签名为什么是安全的,需要回答一个更根本的问题:你怎么证明你知道某个秘密,同时又不把秘密本身说出来?

这正是**零知识证明(Zero-Knowledge Proof,ZKP)** 解决的问题,而数字签名恰好是 ZKP 的一种特殊实例。

Σ 协议:交互式零知识证明

想象 Peggy 要向 Victor 证明她知道私钥 x(满足 Y = g^x),但不想透露 x 本身——这就是 Schnorr 识别协议(Schnorr Identification Protocol):

  1. 承诺(Commitment):Peggy 生成随机数 k,计算 R = g^k,将 R 发给 Victor
  2. 挑战(Challenge):Victor 发送随机挑战值 c
  3. 响应(Response):Peggy 计算 s = k + c·x,发给 Victor

Victor 验证 g^s = g^(k+cx) = R · Y^c 是否成立。这个三步模式(承诺→挑战→响应)被称为 Σ 协议(Sigma Protocol)——Victor 学不到任何关于 x 的信息,但能确信 Peggy 知道 x。

Fiat-Shamir 变换:从交互式到签名

交互式协议有个实际问题:Victor 必须在线。1986 年,Fiat 和 Shamir 提出了关键技巧:让 Peggy 自己扮演 Victor,用哈希函数生成挑战值

c = H(R || message)

挑战值不再来自真实验证者,而来自哈希函数。只要哈希输出不可预测,安全性就与交互版本等价。这正是 Schnorr 签名的数学本质——签名本质上是**非交互式零知识证明(NIZK)**。

graph TD
    A["Σ 协议<br/>(交互式,需 Victor 在线)"] -- "Fiat-Shamir 变换<br/>c = H(R || msg)" --> B["Schnorr 签名<br/>(非交互式 NIZK)"]
    B -- "Edwards25519 + 确定性 nonce" --> C["Ed25519"]
    B -- "secp256k1 + Taproot" --> D["比特币聚合签名"]

    style A fill:transparent,stroke:#0288d1,color:#adbac7
    style B fill:transparent,stroke:#388e3c,color:#adbac7
    style C fill:transparent,stroke:#7b1fa2,color:#adbac7
    style D fill:transparent,stroke:#f57c00,color:#adbac7

📌 ZKP 的应用远不止于签名:现代区块链(如 Zcash、zkSync)使用更通用的 ZK-SNARK / ZK-STARK 实现隐私保护的计算验证。这些进阶话题详见「下一代密码学」(../next-generation/)。参考「散列函数与完整性保护」(../hashing-and-integrity/) 了解哈希函数在 Fiat-Shamir 变换中承担的角色。

🔢 RSA 签名

当你需要一种被几乎所有系统广泛支持的签名算法时,RSA 几乎是默认选择。自 1977 年发表以来,RSA 一直是公钥密码学的"主力军"。

RSA 签名原理

RSA 密钥对由模数 N(两个大质数 P 和 Q 的乘积)、公钥指数 E 和私钥指数 D 组成,满足 E × D ≡ 1 mod ((P-1)(Q-1))。

RSA 签名的基本运算非常简洁:

  • 签名:S = PAD(H(m))^D mod N
  • 验证:PAD(H(m)) == S^E mod N

其中 PAD() 是填充函数,H(m) 是消息摘要。签名和验证互为逆运算——签名用私钥做指数运算,验证用公钥做指数运算。

💡 这个"签名 = 私钥加密"的直觉虽然简化了,但在调试时很有用:用公钥"解密"签名值可以恢复填充后的哈希,帮你排查签名失败的原因。

PKCS#1 v1.5 签名

PKCS#1 v1.5 是最经典的 RSA 签名方案(RFC 8017),使用 type 1 填充:

PAD(h) = 0x00 || 0x01 || 0xFF...0xFF || 0x00 || DigestInfo(h)

其中 DigestInfo 包含摘要算法的 OID 和摘要值的 DER 编码。由于填充中不包含随机部分,PKCS#1 v1.5 是**确定性**的——同一密钥对同一消息总是产生相同的签名。

RSA PKCS#1 v1.5 签名与验证
// 完整示例见 RsaSignatureTest.java
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", "BC");
keyPairGenerator.initialize(2048);
KeyPair keyPair = keyPairGenerator.generateKeyPair();

byte[] messageBytes = "RSA PKCS#1 v1.5 签名测试消息".getBytes();

// 用私钥签名
Signature signature = Signature.getInstance("SHA256withRSA", "BC");
signature.initSign(keyPair.getPrivate());
signature.update(messageBytes);
byte[] signatureBytes = signature.sign(); // 固定 256 字节(2048 / 8)

// 用公钥验证
signature.initVerify(keyPair.getPublic());
signature.update(messageBytes);
assertTrue(signature.verify(signatureBytes));

RSA 签名长度等于密钥长度(字节数):2048 位密钥产生 256 字节签名,4096 位密钥产生 512 字节签名。

为什么 RSA 签名有「教科书」漏洞

当你第一次学习 RSA 时,可能听说过这样一个简洁描述:签名 = 消息^私钥 mod N,验证 = 签名^公钥 mod N。这个"教科书版 RSA 签名"看起来优雅,但它隐藏了一个致命的**可伪造性漏洞**。

无消息伪造:不需要私钥的"签名"

想象你是攻击者,以下步骤可以在完全不接触私钥的情况下伪造合法签名:

  1. 随机挑选任意数 σ(即"伪造签名")
  2. 用公钥计算 m = σ^e mod N
  3. 宣称"消息 m 的签名就是 σ"

验证者用公钥验算 σ^e mod N = m,与声称的消息完全匹配——验证通过。攻击者根本没有碰私钥。

💡 这就像反着走棋局:你先随机摆好"终局",再声称这是某局对弈的结果——只要规则允许这种反向推导,伪造就成立。无哈希、无填充的裸 RSA 恰好允许这种"倒推"。

PKCS#1 v1.5 的实现级漏洞

为对抗无消息伪造,PKCS#1 v1.5 引入了严格的填充格式(DigestInfo + 0xFF 序列),使倒推不再可行。但 1998 年 Bleichenbacher 发现了更深的问题:如果实现代码对填充校验不够严格,攻击者可以在不知道私钥的情况下伪造签名

2006 年 Bleichenbacher 给出了具体的签名伪造攻击。更严重的是,2019 年一项研究(Chau et al.)发现大量知名开源库存在这一缺陷——这不是罕见的边界情况,而是系统性的实现错误。

⚠️ RSA PKCS#1 v1.5 签名**不是算法本身不安全**,而是"正确实现极其困难"。现有系统为向后兼容不得不继续使用时,请严格校验填充格式,并考虑迁移到 RSA-PSS(见下节)。

RSA-PSS 签名

虽然 PKCS#1 v1.5 至今未被实际攻破,但密码学界更推荐使用 RSA-PSS(Probabilistic Signature Scheme,概率签名方案)。PSS 通过引入随机盐值(salt)使签名具有随机性,并且在理论上被证明是安全的。

PSS 的填充过程比 PKCS#1 v1.5 复杂得多,核心思路是使用掩码生成函数 MGF(Mask Generation Function)对填充数据进行随机化处理。

RSA-PSS 签名与验证(自定义 PSS 参数)
// 完整示例见 RsaSignatureTest.java
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", "BC");
keyPairGenerator.initialize(2048);
KeyPair keyPair = keyPairGenerator.generateKeyPair();

byte[] messageBytes = "RSA-PSS 签名测试消息".getBytes();

// 配置 PSS 参数
PSSParameterSpec pssSpec = new PSSParameterSpec(
    "SHA-256",                       // 消息摘要算法
    "MGF1",                           // 掩码生成函数
    new MGF1ParameterSpec("SHA-256"), // MGF 使用的摘要算法
    32,                               // 盐值长度(字节)
    1                                 // Trailer Field(固定为 1)
);

// 用私钥签名
Signature signature = Signature.getInstance("SHA256withRSAandMGF1", "BC");
signature.setParameter(pssSpec);
signature.initSign(keyPair.getPrivate());
signature.update(messageBytes);
byte[] signatureBytes = signature.sign();

// 用公钥验证(必须使用相同的 PSS 参数!)
signature.setParameter(pssSpec);
signature.initVerify(keyPair.getPublic());
signature.update(messageBytes);
assertTrue(signature.verify(signatureBytes));

⚠️ RSA-PSS 验证的两个常见陷阱: 1. 盐值长度不匹配:盐值长度不编码在签名中,验证时必须指定与签名时相同的盐值长度 2. MGF 摘要不一致:MGF 使用的摘要算法应与消息摘要相同,确保安全强度一致

PKCS#1 v1.5 vs PSS 对比

特性 PKCS#1 v1.5 RSA-PSS
确定性 是(同一签名) 否(随机盐值,除非盐值长度为 0)
安全性证明 无理论证明 有可证明安全性
兼容性 极广泛(几乎所有系统) 较新系统支持
算法名称 SHA256withRSA SHA256withRSAandMGF1
参数配置 无需额外参数 需要 PSSParameterSpec
推荐程度 兼容场景可用 新项目优先选择

签名延展性攻击

你以为一个合法签名在数学上只有唯一的形式?对于大多数签名方案来说,并非如此

ECDSA 的天然可延展性

ECDSA 签名由两个整数 (r, s) 组成。由于椭圆曲线的对称性,如果 (r, s) 是消息 m 的合法签名,那么 (r, -s mod n) 也是合法签名——n 是曲线阶。攻击者不需要私钥,就能从任何已有签名生成另一个同样有效的签名。这种性质叫做**签名延展性(Signature Malleability)**。

比特币 MtGox 事件(2014)

2014 年 2 月,曾是最大比特币交易所的 MtGox 宣告破产,损失约 85 万枚比特币。调查显示,攻击者利用签名延展性修改了提款交易的签名:

  1. 用户发起提款,广播包含 ECDSA 签名的交易
  2. 攻击者截获,用 -s mod n 替换 s,重新广播篡改版本
  3. 两笔交易签名都合法,但**交易 ID(txid)不同**(txid 是含签名的整个交易的哈希)
  4. MtGox 系统误认为原始交易失败,触发重复发款逻辑
sequenceDiagram
    participant User as 用户
    participant Attacker as 攻击者
    participant Network as 比特币网络

    User->>Network: 广播提款交易 sig=(r, s)
    Attacker->>Network: 广播篡改交易 sig=(r, -s mod n)
    Note over Network: 两者签名均合法,txid 不同
    Network->>Attacker: 篡改交易先被确认
    User->>Network: 原始交易显示未确认
    Note over User: 向 MtGox 投诉"提款失败"
    Note over Network: MtGox 误判重复发款 ❌

缓解措施

  • Bitcoin SegWit(隔离见证):将签名数据移出 txid 计算范围,使 txid 不再受签名内容影响,从根本上解决了比特币场景的延展性问题
  • Ed25519 的 RFC 8032 规范:要求验证时严格检查 s < n/2,拒绝"等价但不同"的签名,减少延展性攻击面
  • BLS 签名:基于双线性配对,具有天然的签名唯一性,同时支持聚合;但计算成本更高,适合对签名大小和聚合有严格要求的场景

⚠️ 签名延展性并不意味着签名方案被"攻破"——攻击者仍然无法伪造针对自己选定消息的签名(EUF-CMA 安全性不受影响)。但在**依赖"签名唯一性"的协议**(如区块链 txid 去重)中,延展性可能成为逻辑漏洞的入口。设计依赖签名的协议时,不要假设同一消息只会有一种合法签名。

🔏 SM2 国密签名

当你的项目需要满足中国政府的合规要求时(如电子签章、政务系统、金融领域),SM2 是必须了解的签名算法。SM2 由中国国家密码管理局发布,基于 256 位椭圆曲线 sm2p256v1,搭配 SM3 摘要算法。

SM2 的独特之处

SM2 与其他 EC 签名算法最大的区别在于**消息预处理**:在签名之前,SM2 会先计算一个"用户身份前缀" ZA,并将其与原始消息拼接。ZA 的计算涉及:

  • 用户 ID(默认为 "1234567812345678" 的 ASCII 编码)
  • 曲线参数 A、B
  • 基点 G 的坐标
  • 签名者公钥 Q 的坐标
ZA = H(ID长度 || ID || A || B || xG || yG || xQ || yQ)
m' = ZA || m

💡 这个预处理步骤相当于给每个签名者打上"身份烙印",使得不同用户的签名即使使用相同曲线也无法混淆。

SM2ParameterSpec

Bouncy Castle 通过 SM2ParameterSpec 允许你显式指定用户 ID。如果不设置,则使用默认 ID "1234567812345678"。签名和验证双方必须使用相同的 ID,否则验证会失败。

Java 代码示例

SM2 签名与验证
// 完整示例见 Sm2SignatureTest.java
byte[] DEFAULT_ID = "1234567812345678".getBytes();

// 生成 SM2 密钥对(使用 sm2p256v1 曲线)
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC", "BC");
ECNamedCurveParameterSpec sm2CurveSpec =
    ECNamedCurveTable.getParameterSpec("sm2p256v1");
keyPairGenerator.initialize(sm2CurveSpec, new SecureRandom());
KeyPair keyPair = keyPairGenerator.generateKeyPair();

byte[] messageBytes = "SM2 国密签名测试消息".getBytes();

// 用私钥签名(设置用户 ID)
Signature signature = Signature.getInstance("SM3withSM2", "BC");
signature.setParameter(new SM2ParameterSpec(DEFAULT_ID));
signature.initSign(keyPair.getPrivate());
signature.update(messageBytes);
byte[] signatureBytes = signature.sign(); // 约 70-72 字节

// 用公钥验证(必须使用相同的用户 ID)
signature.setParameter(new SM2ParameterSpec(DEFAULT_ID));
signature.initVerify(keyPair.getPublic());
signature.update(messageBytes);
assertTrue(signature.verify(signatureBytes));

// 篡改消息后验证失败
signature.setParameter(new SM2ParameterSpec(DEFAULT_ID));
signature.initVerify(keyPair.getPublic());
signature.update("被篡改的消息".getBytes());
assertFalse(signature.verify(signatureBytes));

SM2 签名由 (r, s) 两个 32 字节整数组成,DER 编码后通常为 70-72 字节。

💡 算法选择建议

当你面对一个需要数字签名的项目时,如何选择算法?以下对比表格覆盖了主要的决策维度:

算法全面对比

维度 DSA ECDSA EdDSA RSA PKCS#1 v1.5 RSA-PSS SM2
数学基础 离散对数 椭圆曲线离散对数 Edwards 曲线 大整数分解 大整数分解 椭圆曲线离散对数
128 位安全密钥长度 3072 位 256 位 255 位 3072 位 3072 位 256 位
签名大小 ~56 字节 ~72 字节 64 字节 256 字节 256 字节 ~72 字节
确定性 否(可用 DDSA) 否(可用 ECDDSA)
侧信道抗性 一般 一般 优秀 一般 一般 一般
兼容性 一般 广泛 较新 极广泛 较广泛 中国标准
算法名称 SHA256withDSA SHA256withECDSA EdDSA SHA256withRSA SHA256withRSAandMGF1 SM3withSM2
额外参数 PSSParameterSpec SM2ParameterSpec

选择建议

  • 通用互联网服务(TLS、JWT、代码签名):优先 ECDSA P-256(性能好、签名小、兼容性强)或 Ed25519(确定性、性能优秀)
  • 需要广泛兼容性(PDF 签名、XML 签名、旧系统集成)RSA PKCS#1 v1.5(2048 位以上)
  • 追求理论安全性RSA-PSS(有可证明安全性的证明)
  • 中国合规场景(政务、金融、电子签章)SM2(国密标准强制要求)
  • 嵌入式/物联网设备Ed25519(小密钥、小签名、确定性、无随机数依赖)
  • 避免使用:DSA 3072(密钥太大、性能不如 ECDSA)、SHA-1 签名

💡 如果没有特殊限制,Ed25519 是当前最推荐的新项目选择——确定性签名消除了随机数安全隐患,签名紧凑(64 字节),性能优秀,且被越来越多的协议和框架支持。

📚 参考来源(本笔记增强部分)

  • David Wong, Real-World Cryptography (Manning, 2021), Chapter 7: Signatures and zero-knowledge proofs
  • 章节文本:会话工作区 files/rwc-chapters/ch07.txt