mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
4810 字
14 分钟
数字签名
2026-03-23

MAC 能证明消息没被篡改,也能证明它来自某个持有密钥的人——但它无法证明那个人是谁。Alice 和 Bob 共享密钥 K,Alice 用 K 签了一条消息发给 Bob,Bob 验证通过。但 Bob 也能用 K 伪造一条”来自 Alice”的消息。在法庭上,Alice 完全可以说”这条消息是 Bob 自己生成的”。**数字签名(Digital Signature)**用非对称密钥解决了这个问题:私钥签名、公钥验证,只有私钥持有者能签名,任何人都能验证。

一、数字签名基础#

1.1 为什么 MAC 不够?#

MAC 能保证消息没有被篡改,且确实来自某个持有密钥的人。但问题在于——对称密钥意味着双方都能生成有效的 MAC。假设 Alice 和 Bob 共享密钥 K:

  • Alice 用 K 生成 MAC 发给 Bob → Bob 可以验证
  • 但 Bob 也能用 K 伪造一条”来自 Alice”的消息
  • 在法庭上,Alice 可以说”这条消息可能是 Bob 自己生成的”
场景MAC 够用吗?数字签名
API 请求认证双方互信即可过度设计
JWT 内部服务微服务间共享密钥可选
电子合同签署需要不可抵赖必须
代码发布签名需要公开验证必须
证书签发CA 必须不可抵赖必须
区块链交易需要公开验证必须
Tip

简单原则:如果只有两方参与且互信,用 MAC;如果需要第三方验证或不可抵赖,用数字签名。

1.2 签名与验证#

数字签名使用私钥签名、公钥验证,提供认证 + 完整性 + 不可抵赖:

graph LR subgraph "签名" M1["消息"] --> H1["哈希"] H1 --> SIG["私钥签名"] SIG --> S["签名值"] end subgraph "验证" M2["消息"] --> H2["哈希"] S --> VER["公钥验证"] H2 --> VER VER -->|"匹配"| OK["有效"] VER -->|"不匹配"| FAIL["无效"] end

1.3 签名的三种用途#

用途含义实际场景
认证(Authentication)确认签名者身份SSH 登录、代码提交
完整性(Integrity)消息未被篡改软件分发、固件升级
不可抵赖(Non-repudiation)签名者无法否认电子合同、金融交易
graph TB SIG["数字签名"] --> A["认证<br/>你是谁?"] SIG --> B["完整性<br/>消息没变?"] SIG --> C["不可抵赖<br/>你签的?"] A --> A1["SSH 公钥认证"] A --> A2["TLS 证书验证"] B --> B1["软件包签名"] B --> B2["Git commit 签名"] C --> C1["电子合同"] C --> C2["区块链交易"]
属性MAC数字签名
认证
完整性
不可抵赖(对称密钥)(非对称密钥)
验证者仅密钥持有者任何人(公钥公开)

二、RSA-PSS 签名#

2.1 RSA 签名的数学原理#

RSA 签名本质上是”用私钥加密”——更准确地说,是对消息摘要做模幂运算:

签名:s = H(m)^d mod n
验证:H(m) = s^e mod n
其中:
- d 是私钥指数
- e 是公钥指数(通常为 65537)
- n 是模数(两个大素数的乘积)

注意:直接对原始消息做 RSA 运算是不安全的,必须先哈希再填充再签名。这就是填充方案的作用。

2.2 为什么不用 PKCS#1 v1.5?#

PKCS#1 v1.5 填充结构过于简单,存在多种攻击:

填充方案安全性攻击可证明安全
PKCS#1 v1.5Bleichenbacher、存在性伪造
RSA-PSS无已知实际攻击是(在 RSA 假设下)

PKCS#1 v1.5 的填充格式:0x00 | 0x01 | FF FF ... FF | 0x00 | DigestInfo。问题在于验证时只检查前缀和后缀,中间的 FF 字节数量没有被严格校验——这为伪造留下了空间。

2.3 PKCS#1 v1.5 vs PSS 详细对比#

graph TB subgraph "PKCS#1 v1.5 填充" P1["0x00 0x01"] --> P2["FF FF ... FF"] P2 --> P3["0x00"] P3 --> P4["DigestInfo<br/>(算法OID + 哈希值)"] NOTE1["验证不严格<br/>存在伪造空间"] end subgraph "RSA-PSS 填充" S1["0x00"] --> S2["随机盐值 salt"] S2 --> S3["MGF1 掩码生成"] S3 --> S4["哈希值 H(m)"] S4 --> S5["0xBC 尾标记"] NOTE2["每步严格验证<br/>可证明安全"] end
维度PKCS#1 v1.5RSA-PSS
填充结构固定格式随机化(含盐值)
安全证明有(随机预言模型)
签名确定性确定(同一消息同一签名)随机(同一消息不同签名)
存在性伪造可能(低指数时)不可行
Bleichenbacher受影响不受影响
兼容性广泛(旧系统)较新(TLS 1.3 推荐)
Note

为什么 PKCS#1 v1.5 还在用?答案是兼容性。大量遗留系统(旧版 Java、嵌入式设备、智能卡)只支持 v1.5。迁移需要时间,但新系统必须使用 PSS。TLS 1.3 已经明确要求 RSA-PSS。

2.4 Bleichenbacher 攻击#

1998 年 Daniel Bleichenbacher 发现了针对 PKCS#1 v1.5 的适应性选择密文攻击:

  1. 攻击者构造伪造密文,发送给服务器
  2. 服务器返回”填充是否有效”的错误信息
  3. 利用这个预言机(oracle),逐步缩小明文空间
  4. 经过约 100 万次查询,恢复任意密文对应的明文

2018 年以 ROBOT(Return Of Bleichenbacher’s Oracle Threat)卷土重来,影响了 Facebook、PayPal 等众多网站。

# Python: RSA-PSS 签名(推荐方式)
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
message = b"Important document"
# 签名
signature = private_key.sign(
message,
padding.PSS(
mgf=padding.MGF1(hashes.SHA256()),
salt_length=padding.PSS.MAX_LENGTH
),
hashes.SHA256()
)
# 验证
public_key = private_key.public_key()
public_key.verify(signature, message, padding.PSS(
mgf=padding.MGF1(hashes.SHA256()),
salt_length=padding.PSS.MAX_LENGTH
), hashes.SHA256())
// Go: RSA-PSS 签名
privateKey, _ := rsa.GenerateKey(rand.Reader, 2048)
hashed := sha256.Sum256([]byte("Important document"))
sig, _ := rsa.SignPSS(rand.Reader, privateKey, crypto.SHA256, hashed[:], nil)
err := rsa.VerifyPSS(&privateKey.PublicKey, crypto.SHA256, hashed[:], sig, nil)

三、ECDSA 签名#

3.1 ECDSA 签名算法详解#

ECDSA 基于 非对称加密 中介绍的椭圆曲线。

签名算法:

输入:私钥 d,消息 m
1. 计算哈希 e = H(m)
2. 生成随机数 k(1 ≤ k ≤ n-1)
3. 计算曲线点 (x₁, y₁) = k · G
4. 计算 r = x₁ mod n(若 r = 0 则重新选 k)
5. 计算 s = k⁻¹(e + r·d) mod n(若 s = 0 则重新选 k)
6. 输出签名 (r, s)

验证算法:

输入:公钥 Q,消息 m,签名 (r, s)
1. 验证 r, s ∈ [1, n-1]
2. 计算哈希 e = H(m)
3. 计算 w = s⁻¹ mod n
4. 计算 u₁ = e·w mod n,u₂ = r·w mod n
5. 计算曲线点 (x₁, y₁) = u₁·G + u₂·Q
6. 验证 r ≡ x₁ mod n

验证时通过 u₁·G + u₂·Q 重建了签名时的 k·G,因为 u₁·G + u₂·Q = w·(e + r·d)·G = k·G

3.2 为什么 k 值如此关键#

ECDSA 签名中的随机数 k 是整个安全性的命门。如果 k 出了问题,私钥就会泄露:

场景一:k 值重复

签名1:(r, s₁),其中 s₁ = k⁻¹(e₁ + r·d) mod n
签名2:(r, s₂),其中 s₂ = k⁻¹(e₂ + r·d) mod n
k 相同 → r 相同,所以:
k = (e₁ - e₂) / (s₁ - s₂) mod n
d = (s₁·k - e₁) / r mod n ← 私钥恢复!

PS3 被黑事件(2010)

Sony 的 PS3 使用 ECDSA 签名验证游戏是否为正版,但所有签名使用相同的 k 值(硬编码在代码中)。黑客 Hector Martin 发现后,用上述方法恢复了 Sony 的私钥,从而可以签名任意 PS3 软件。

# 演示:k 值重复导致私钥泄露
def recover_private_key(e1, e2, s1, s2, r, n):
"""从两个使用相同 k 的签名中恢复私钥"""
k = ((e1 - e2) * pow(s1 - s2, -1, n)) % n
d = ((s1 * k - e1) * pow(r, -1, n)) % n
return d

场景二:k 值可预测

即使 k 不完全相同,只要部分比特可预测,也能用格攻击(lattice attack)恢复私钥。2019 年有研究者发现某些比特币钱包的 k 值只有约 128 位随机性,用 LLL 格基规约算法就能恢复私钥。

Warning

ECDSA 的 k 值安全是整个签名系统的阿喀琉斯之踵。如果 RNG 在签名时出问题(比如虚拟机快照导致熵不足),私钥就会泄露。这就是为什么 EdDSA 的确定性设计如此重要。

3.3 确定性 ECDSA(RFC 6979)#

为了解决 k 值随机性的问题,RFC 6979 提出确定性 k 值生成:

k = HMAC_DRBG(private_key, message_hash)
同一消息 + 同一私钥 → 同一 k → 同一签名

这消除了对高质量 RNG 的依赖,同时保持了 ECDSA 的兼容性。

# Python: 确定性 ECDSA 签名(RFC 6979)
from cryptography.hazmat.primitives.asymmetric.ec import (
generate_private_key, ECDSA, SECP256R1
)
from cryptography.hazmat.primitives import hashes
private_key = generate_private_key(SECP256R1())
message = b"Important document"
# cryptography 库默认使用确定性 k(RFC 6979)
signature = private_key.sign(message, ECDSA(hashes.SHA256()))
public_key = private_key.public_key()
public_key.verify(signature, message, ECDSA(hashes.SHA256()))
// Java: ECDSA 签名
KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC");
kpg.initialize(256); // secp256r1
KeyPair kp = kpg.generateKeyPair();
Signature sig = Signature.getInstance("SHA256withECDSA");
sig.initSign(kp.getPrivate());
sig.update("Important document".getBytes());
byte[] signature = sig.sign();
sig.initVerify(kp.getPublic());
sig.update("Important document".getBytes());
boolean valid = sig.verify(signature);

四、EdDSA 签名(Ed25519)#

4.1 Ed25519 的设计哲学#

Ed25519 由 Daniel J. Bernstein 等人设计,目标非常明确——消除 ECDSA 的所有坑:

ECDSA 的痛点Ed25519 的解决方案
k 值需要随机数确定性签名,不需要 RNG
不同的曲线参数固定曲线(Curve25519),无参数协商
签名格式不统一固定 64 字节签名
验证实现复杂验证公式简单,不易出错
侧信道攻击设计上抗侧信道

4.2 为什么 Ed25519 不需要随机数#

Ed25519 的签名过程完全确定性:

1. 私钥扩展:hash(key) → (prefix, public_key_scalar)
2. 确定性 nonce:r = H(prefix || message) ← 不需要随机数!
3. 签名计算:
R = r · B(B 是基点)
S = (r + H(R || A || message) · a) mod l
4. 输出签名 (R, S),共 64 字节

关键在第 2 步——nonce r 由私钥前缀和消息的哈希决定,而非随机生成。同一消息 + 同一私钥 → 同一签名;不同消息 → 不同 nonce。不依赖系统 RNG,即使在熵不足的环境中也是安全的。

4.3 Ed25519 vs ECDSA 详细对比#

维度ECDSA (secp256r1)Ed25519
曲线Weierstrass 形式Twisted Edwards 形式
密钥大小32 字节(+参数)32 字节(自包含)
签名大小64 字节(DER 编码可变)64 字节(固定)
签名速度快(~2x ECDSA)
验证速度
随机数需要(除非 RFC 6979)不需要(确定性)
批量验证不支持支持(~3x 加速)
安全等级128 位128 位
侧信道需要小心实现设计上抗侧信道
标准化NIST/FIPSRFC 8032

4.4 Ed25519 代码示例#

# Python: Ed25519 签名(推荐)
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
private_key = Ed25519PrivateKey.generate()
message = b"Important document"
signature = private_key.sign(message)
public_key = private_key.public_key()
public_key.verify(signature, message)
// Go: Ed25519 签名
publicKey, privateKey, _ := ed25519.GenerateKey(nil)
message := []byte("Important document")
signature := ed25519.Sign(privateKey, message)
valid := ed25519.Verify(publicKey, message, signature)
// Java: Ed25519 签名(需要 Java 15+)
KeyPairGenerator kpg = KeyPairGenerator.getInstance("Ed25519");
KeyPair kp = kpg.generateKeyPair();
Signature sig = Signature.getInstance("Ed25519");
sig.initSign(kp.getPrivate());
sig.update("Important document".getBytes());
byte[] signature = sig.sign();

4.5 Ed25519 在 SSH 与 Git 中的应用#

# 生成 Ed25519 SSH 密钥
ssh-keygen -t ed25519 -C "your@email.com"
# Git 使用 Ed25519 签名 commit
git config user.signingkey ssh-ed25519 AAAAC3NzaC1lZDI1NTE5...
git commit -S -m "signed commit"
工具旧方案推荐方案
SSH 密钥RSA 2048/4096Ed25519
Git 签名GPG (RSA)SSH (Ed25519)
SSH 证书RSAEd25519
Tip

如果你还在用 RSA SSH 密钥,建议迁移到 Ed25519。密钥更短、速度更快、安全性更高。一条命令即可:ssh-keygen -t ed25519

五、证书链与信任#

5.1 为什么需要证书链?#

数字签名解决了”消息确实由私钥持有者签署”的问题,但没有解决”这个公钥属于谁”的问题。假设你收到一个 Ed25519 签名的消息,验证通过了——但你怎么知道这个公钥真的是 Alice 的?攻击者 Eve 完全可以生成自己的密钥对,然后声称”这是 Alice 的公钥”。

5.2 证书链的结构#

graph TB ROOT["Root CA<br/>(自签名,预装在系统中)"] INT1["Intermediate CA 1"] INT2["Intermediate CA 2"] EE1["End-Entity 证书<br/>(你的网站)"] EE2["End-Entity 证书<br/>(你的应用)"] ROOT -->|"签名"| INT1 ROOT -->|"签名"| INT2 INT1 -->|"签名"| EE1 INT2 -->|"签名"| EE2 style ROOT fill:#ff6b6b,color:#fff style INT1 fill:#ffd93d,color:#333 style INT2 fill:#ffd93d,color:#333 style EE1 fill:#6bcb77,color:#fff style EE2 fill:#6bcb77,color:#fff

证书链验证过程:

  1. 从 end-entity 证书开始
  2. 找到签发者(issuer)的中间 CA 证书
  3. 用中间 CA 的公钥验证 end-entity 证书的签名
  4. 继续向上,直到到达 Root CA
  5. 检查 Root CA 是否在系统信任存储中
证书类型签名者用途有效期
Root CA自己(自签名)信任锚点20-25 年
Intermediate CARoot CA 或上级中间 CA签发终端证书5-10 年
End-EntityIntermediate CA实际使用(TLS、代码签名)90 天-1 年

5.3 为什么需要中间 CA?#

  1. 安全隔离:Root CA 的私钥存储在离线 HSM 中,极少使用。如果 Root CA 密钥泄露,整个信任体系崩塌
  2. 分工管理:不同中间 CA 可以负责不同业务(TLS 证书、代码签名、S/MIME)
  3. 吊销效率:中间 CA 被泄露只需吊销该中间 CA,影响范围有限
Note

证书与 PKI 的完整内容将在 证书与 PKI 中深入展开,包括 OCSP、CRL、Let’s Encrypt 等。

六、签名流程与最佳实践#

6.1 完整签名工作流#

sequenceDiagram participant S as 签名者 participant CA as 证书机构 participant V as 验证者 Note over S,CA: 准备阶段 S->>CA: 1. 提交 CSR(证书签名请求) CA->>CA: 2. 验证身份 CA->>S: 3. 签发证书(含公钥) Note over S,V: 签名阶段 S->>S: 4. 计算消息摘要 H(m) S->>S: 5. 用私钥签名 Sign(sk, H(m)) S->>V: 6. 发送 (m, signature, certificate) Note over V: 验证阶段 V->>V: 7. 验证证书链(至 Root CA) V->>V: 8. 检查证书有效期与吊销状态 V->>V: 9. 提取公钥 V->>V: 10. 计算摘要 H(m) V->>V: 11. 验证签名 Verify(pk, signature, H(m))

6.2 签名还是加密?先签名还是先加密?#

方案做法安全性问题
先签名后加密Sign → Encrypt推荐
先加密后签名Encrypt → Sign有风险可能被替换签名
签名并加密Encrypt && Sign最安全实现复杂

推荐”先签名后加密”:先对明文签名,然后对(明文 + 签名)加密。这样接收者解密后可以验证签名来源。

需求方案
只需完整性 + 认证签名(或 MAC)
只需机密性加密
机密性 + 不可抵赖先签名后加密
机密性 + 认证AEAD(加密 + MAC)

6.3 多签名场景#

顺序签名(Counter-signing):文档 → A 签名 → (文档, sig_A) → B 签名 → (文档, sig_A, sig_B)。验证时按序验证。

并行签名(Independent signing):文档 → A 签名 → sig_A,文档 → B 签名 → sig_B。最终 (文档, sig_A, sig_B),独立验证。

方案优点缺点适用场景
顺序签名有先后顺序,后签者认可前签验证必须按序合同审批流
并行签名独立验证,效率高无先后关系多方见证

6.4 实践:签名 JWT#

# Python: 使用 Ed25519 签名 JWT
import json, base64
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
private_key = Ed25519PrivateKey.generate()
def base64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b'=').decode()
header = base64url(json.dumps({"alg": "EdDSA", "typ": "JWT"}).encode())
payload = base64url(json.dumps({"sub": "user123", "exp": 1735689600}).encode())
signing_input = f"{header}.{payload}".encode()
signature = private_key.sign(signing_input)
jwt_token = f"{header}.{payload}.{base64url(signature)}"

七、签名攻击#

7.1 k 值重用攻击(ECDSA)#

前面已经讲过 PS3 事件。这里给出更完整的攻击代码:

# ECDSA k 值重用攻击演示
def ecdsa_k_reuse_attack(e1, e2, s1, s2, r, n):
"""当两个 ECDSA 签名使用相同的 k 值时,恢复私钥"""
k = ((e1 - e2) * pow(s1 - s2, -1, n)) % n
d = ((s1 * k - e1) * pow(r, -1, n)) % n
return d, k
事件年份原因后果
PS3 被黑2010Sony 硬编码 k 值私钥泄露,可签名任意软件
比特币钱包2013-2019RNG 缺陷导致 k 部分可预测多个地址私钥被恢复
Android Bitcoin2013Android SecureRandom 漏洞比特币被盗

7.2 存在性伪造攻击#

存在性伪造指攻击者可以在不知道私钥的情况下伪造一个有效签名,虽然无法控制消息内容。

PKCS#1 v1.5 的存在性伪造:如果验证实现不严格检查 DigestInfo 中的算法标识符,攻击者可以构造一个”看起来合法”但实际对应不同哈希算法的签名。

低指数 RSA 的存在性伪造:当 e = 3 时,攻击者可以计算 使其格式恰好符合 PKCS#1 v1.5 填充,从而伪造签名。

攻击条件防御
PKCS#1 v1.5 伪造验证不严格使用 RSA-PSS
低指数 RSA 伪造e = 3 + PKCS#1 v1.5e ≥ 65537 + PSS
ECDSA 伪造弱曲线使用标准曲线

7.3 时序攻击#

签名验证的时间可能泄露私钥信息。攻击者通过精确测量验证时间,逐步推断私钥的比特。

# 不安全的比较(时序泄露)
def verify_unsafe(computed, received):
return computed == received # 逐字节比较,不等时立即返回
# 安全的比较(常数时间)
import hmac
def verify_safe(computed, received):
return hmac.compare_digest(computed, received) # 总是比较全部字节

2011 年 Brumley 和 Hakioja 展示了对 OpenSSL ECDSA 实现的时序攻击,通过网络延迟测量恢复了 TLS 服务器的私钥。

7.4 故障注入攻击#

故障注入通过物理手段(电压毛刺、激光、电磁脉冲)干扰签名计算,使设备输出一个错误的签名。

RSA-CRT 故障攻击:RSA 签名通常使用中国剩余定理(CRT)加速。一次故障签名就能分解 n:

正常:s = s_p · q · q_inv + s_q · p · p_inv mod n
故障:s' = s_p' · q · q_inv + s_q · p · p_inv mod n
gcd(s - s', n) = p ← 因子分解!
攻击方式目标难度防御
电压毛刺智能卡签名前验证结果
激光注入HSM故障检测电路
电磁脉冲嵌入式设备冗余计算

7.5 重复签名密钥选择攻击(DSKS)#

DSKS 攻击者生成一个不同的密钥对,使得同一个签名在两个密钥下都验证通过。这破坏了不可抵赖性——Alice 签名的文档,攻击者可以声称”这是用我的密钥签的”。

防御方法:在签名中包含公钥的哈希,或使用证书绑定公钥身份。

7.6 攻击防御总览#

攻击目标防御代码示例
k 值重用ECDSA 私钥恢复EdDSA 或确定性 k(RFC 6979)见 3.3 节
存在性伪造PKCS#1 v1.5 无消息伪造使用 RSA-PSS见 2.3 节
时序攻击通过签名时间推断私钥常数时间实现hmac.compare_digest
故障注入畸改签名计算恢复私钥签名后验证 + 冗余计算签名后 self-verify
DSKS破坏不可抵赖性证书绑定 + 公钥哈希签名包含公钥指纹
BleichenbacherRSA PKCS#1 v1.5 填充预言机使用 RSA-PSS见 2.4 节

七·附、实践:数字签名与验证#

数字签名的概念并非凭空出现。1976 年,Diffie 和 Hellman 在《New Directions in Cryptography》中首次提出”数字签名”的概念——用私钥生成、公钥验证的数学构造,实现手写签名的三大法律属性:真实性(签名者身份可验证)、完整性(签名与文档绑定)、不可抵赖性(签名者无法否认)。1977 年 RSA 诞生,成为第一个实用的数字签名方案;1991 年 NIST 发布 DSA 标准;2000 年代椭圆曲线签名(ECDSA)因更短的密钥和签名被广泛采用;2017 年 Ed25519 作为确定性签名的代表,成为新项目的首选。

从 RSA 到 EdDSA 的演进,核心驱动力是安全性与效率的平衡:RSA 签名需要 2048+ 位密钥和 256 字节签名,而 Ed25519 只需 32 字节密钥和 64 字节签名,提供同等 128 位安全等级。更重要的是,Ed25519 的确定性签名消除了 ECDSA 的随机数 k 重用风险——2010 年 Sony PlayStation 3 就因 ECDSA 的 k 值重用导致私钥泄露。

附.1 前置知识#

  • Python 3.8+ + cryptography 库(pip install cryptography
  • 理解本章前七节的理论内容(RSA-PSS、ECDSA、EdDSA、证书链)

附.2 RSA-PSS 签名与验证#

RSA-PSS 是 RSA 签名的推荐方案,使用概率性签名方案(Probabilistic Signature Scheme)替代不安全的 PKCS#1 v1.5:

from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes
# 生成 RSA-2048 密钥对
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_key = private_key.public_key()
message = b"Important document to sign"
# RSA-PSS 签名
signature = private_key.sign(
message,
padding.PSS(
mgf=padding.MGF1(hashes.SHA256()),
salt_length=padding.PSS.MAX_LENGTH
),
hashes.SHA256()
)
print(f"签名长度: {len(signature)} bytes")
print(f"签名(hex): {signature.hex()[:40]}...")
# 验证签名
try:
public_key.verify(signature, message,
padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH),
hashes.SHA256())
print("验证: 通过 ")
except Exception as e:
print(f"验证: 失败 — {e}")
# 篡改消息后验证
tampered = b"Tampered document to sign"
try:
public_key.verify(signature, tampered,
padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH),
hashes.SHA256())
except Exception:
print("篡改后验证: 失败 — 检测到篡改")
签名长度: 256 bytes
签名(hex): 50bf2f3a939a87d32759e76256ab4f6d610e8ecd...
验证: 通过
篡改后验证: 失败 — 检测到篡改

注:RSA-PSS 的 salt_length=MAX_LENGTH 表示使用最大允许的 salt 长度,提供最强的安全性。PSS 的概率性意味着同一消息多次签名结果不同。

附.3 ECDSA 签名与验证#

ECDSA 使用椭圆曲线提供与 RSA 同等安全性,但密钥和签名更短:

from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import hashes
ecc_private = ec.generate_private_key(ec.SECP256R1())
ecc_public = ecc_private.public_key()
ecdsa_sig = ecc_private.sign(message, ec.ECDSA(hashes.SHA256()))
print(f"ECDSA 签名长度: {len(ecdsa_sig)} bytes")
print(f"签名(hex): {ecdsa_sig.hex()[:40]}...")
# 验证
try:
ecc_public.verify(ecdsa_sig, message, ec.ECDSA(hashes.SHA256()))
print("验证: 通过 ")
except Exception as e:
print(f"验证: 失败 — {e}")
# 篡改检测
try:
ecc_public.verify(ecdsa_sig, tampered, ec.ECDSA(hashes.SHA256()))
except Exception:
print("篡改后验证: 失败 — 检测到篡改")
ECDSA 签名长度: 72 bytes
签名(hex): 3046022100ae7eb83486c33e9e3208627785b37d...
验证: 通过
篡改后验证: 失败 — 检测到篡改

注意:ECDSA 每次签名使用不同的随机数 k,因此同一消息多次签名结果不同。如果 k 值重用或可预测,攻击者可以恢复私钥——这就是 Sony PS3 被破解的原因。

附.4 Ed25519 签名与验证#

Ed25519 是现代签名算法的首选:确定性签名、常数时间实现、无随机数依赖:

from cryptography.hazmat.primitives.asymmetric import ed25519
ed_private = ed25519.Ed25519PrivateKey.generate()
ed_public = ed_private.public_key()
ed_sig = ed_private.sign(message)
print(f"Ed25519 签名长度: {len(ed_sig)} bytes (固定 64 bytes)")
print(f"签名(hex): {ed_sig.hex()[:40]}...")
# 验证
try:
ed_public.verify(ed_sig, message)
print("验证: 通过 ")
except Exception as e:
print(f"验证: 失败 — {e}")
# 确定性签名:同一消息两次签名结果相同
ed_sig2 = ed_private.sign(message)
print(f"两次签名相同: {ed_sig == ed_sig2} (Ed25519 是确定性签名)")
Ed25519 签名长度: 64 bytes (固定 64 bytes)
签名(hex): d130e75f30fe1ca1cd4c601426042bcf5d175148...
验证: 通过
两次签名相同: True (Ed25519 是确定性签名)

对比 ECDSA 的非确定性签名:

ecdsa_sig2 = ecc_private.sign(message, ec.ECDSA(hashes.SHA256()))
print(f"ECDSA 两次签名相同: {ecdsa_sig == ecdsa_sig2}")
ECDSA 两次签名相同: False

注:Ed25519 的确定性签名消除了 ECDSA 的随机数 k 重用风险,是安全性的根本提升。RFC 6979 为 ECDSA 定义了确定性 k 的生成方法,但 Ed25519 从设计层面就保证了这一点。

附.5 签名算法性能对比#

import time
# 性能基准测试(100 次取平均)
for name, sign_func, verify_func in [
("RSA-PSS", rsa_sign, rsa_verify),
("ECDSA P-256", ecdsa_sign, ecdsa_verify),
("Ed25519", ed_sign, ed_verify),
]:
start = time.perf_counter()
for _ in range(100):
sig = sign_func()
sign_ms = (time.perf_counter() - start) * 10
start = time.perf_counter()
for _ in range(100):
verify_func(sig)
verify_ms = (time.perf_counter() - start) * 10
print(f"{name:15s}: 签名 {sign_ms:.2f} ms, 验证 {verify_ms:.2f} ms")
RSA-PSS : 签名 0.80 ms, 验证 0.04 ms, 签名长度 256 bytes
ECDSA P-256 : 签名 0.03 ms, 验证 0.10 ms, 签名长度 71 bytes
Ed25519 : 签名 0.04 ms, 验证 0.11 ms, 签名长度 64 bytes

附.6 实践小结#

算法密钥大小签名大小签名速度验证速度确定性安全性推荐场景
RSA-PSS 2048256 B256 B112 位兼容旧系统
ECDSA P-25632 B64 B否(需 RFC 6979)128 位TLS 证书
Ed2551932 B64 B128 位新项目首选

关键教训:

  1. 新项目首选 Ed25519:确定性签名消除随机数风险,64 字节签名节省带宽
  2. RSA-PSS 仅用于兼容:256 字节签名和密钥在现代系统中是不必要的开销
  3. 永远不要使用 PKCS#1 v1.5:存在性伪造攻击(Bleichenbacher)使其不安全
  4. 签名后必须验证篡改:每个签名方案都应展示”签名→验证→篡改检测”的完整流程

八、算法选择与总结#

8.1 算法选择决策表#

场景推荐算法原因
新项目通用签名Ed25519确定性、快速、安全
需要兼容旧系统RSA-PSS 2048+广泛支持
TLS 证书RSA-PSS 或 ECDSACA 决定
JWT 签名EdDSA (Ed25519)短签名、快速验证
代码签名RSA-PSS 4096 或 ECDSA P-384高安全等级
区块链Ed25519 或 secp256k1生态决定
智能卡/HSMECDSA P-256硬件支持好

8.2 密钥长度与安全等级#

算法密钥大小签名大小安全等级NIST 推荐
RSA-2048256 字节256 字节112 位2030 年前
RSA-3072384 字节384 字节128 位长期
RSA-4096512 字节512 字节150 位长期
ECDSA P-25632 字节64 字节128 位长期
ECDSA P-38448 字节96 字节192 位长期
Ed2551932 字节64 字节128 位长期

8.3 关键要点#

维度关键要点
RSA-PSSRSA 签名推荐方案,可证明安全,替代 PKCS#1 v1.5
ECDSA椭圆曲线签名,需好的随机数(或 RFC 6979)
EdDSA确定性签名,最安全最简单,新项目首选
证书链Root CA → Intermediate → End-Entity,信任传递
签名流程先哈希后签名,验证证书链,保护私钥
攻击防御确定性签名、PSS 填充、常数时间、故障检测
Note

后量子密码(如 Dilithium、Falcon)正在标准化中。NIST 已在 2024 年正式发布后量子签名标准,但大规模部署仍需时间。详见 后量子密码

上一章探讨了哈希函数与消息认证码。


参考#

  • RFC 6979
  • RFC 8032 — CFRG Elliptic Curve Diffie-Hellman (ECDH) and Signatures

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

数字签名
https://blog.souloss.com/posts/cryptography/digital-signature/
作者
Souloss
发布于
2026-03-23
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时