前言

上一篇已经完成 RSA 的数学搭建:从 n = p q n=pq n=pq、欧拉函数和模逆得到公私钥指数,再用快速模幂执行模指数运算。

如果只停在数学公式,很容易产生两个危险误解:

  1. 把任意消息整数直接计算 M e   m o d   n M^e\bmod n Memodn,就得到了安全的 RSA 加密;
  2. 所谓 RSA 签名,只是“拿私钥把消息加密一次”。

这两种说法都忽略了现代 RSA 最关键的一层:编码与填充方案

裸 RSA 是确定性的,并保留乘法代数结构;直接对消息做私钥运算也无法建立可靠的签名格式。RSA-OAEP 通过随机化编码构造加密方案,RSA-PSS 则把消息摘要、随机盐和掩码生成函数组织成可验证的签名编码。

本文先区分 RSA 加密与数字签名的安全目标,再拆解 OAEP、PSS 的完整结构。重点不是机械记忆字段,而是理解:为什么同一个模指数原语,在不同编码下会成为两种职责完全不同的密码方案。

现代密码学 专栏:
https://blog.csdn.net/r_feynman_/category_13190241.html

Crypto 密码解析实战靶场:https://blog.csdn.net/r_feynman_/category_13194584.html


一、RSA 原语不等于完整密码方案

1.1 RSA 加密原语

设公钥为 ( n , e ) (n,e) (n,e),私钥指数为 d d d。把消息编码成整数 m m m,且满足:

0 ≤ m < n 0\le m<n 0m<n

RSA 公钥原语计算:

c = m e   m o d   n c=m^e\bmod n c=memodn

私钥原语计算:

m = c d   m o d   n m=c^d\bmod n m=cdmodn

这只定义了整数之间的可逆变换,没有回答:

  • 字节串怎样变成整数;
  • 怎样加入随机性;
  • 输入长度如何限制;
  • 解码失败时怎样处理;
  • 怎样抵抗选择密文攻击。

因此,RSAEPRSADP 这类底层原语不能直接充当应用层加密接口。


1.2 RSA 签名原语

签名方向看起来与加密相反。签名者执行私钥运算:

s = m d   m o d   n s=m^d\bmod n s=mdmodn

验证者执行公钥运算:

m ′ = s e   m o d   n m'=s^e\bmod n m=semodn

但这里的 m m m 不应是原始消息,也不只是一个随意计算的哈希值,而应是按照签名标准编码后的代表元。

数字签名需要同时绑定:

  • 消息摘要;
  • 使用的哈希算法;
  • 规定的编码结构;
  • PSS 中的随机盐和长度规则。

所以“签名就是私钥加密”只在模指数方向上有一点表面相似,安全语义并不成立。


1.3 加密和签名解决不同问题

目标 RSA 加密 RSA 签名
主要安全属性 机密性 身份认证、完整性、不可否认性语境
谁执行核心操作 发送方使用接收方公钥 签名者使用自己的私钥
谁检查结果 接收方用私钥解密 任何持有可信公钥的人验证
推荐编码 OAEP PSS
是否处理大文件 否,通常只封装对称密钥 否,通常签消息摘要

签名不会让消息自动保密;加密也不会自动证明发送者身份。若协议同时需要两种属性,应分别设计认证和加密流程,而不是混用一对 RSA 操作。


二、裸 RSA 为什么不安全

2.1 确定性泄露消息相等关系

裸 RSA 加密为:

c = m e   m o d   n c=m^e\bmod n c=memodn

固定公钥下,同一个 m m m 永远得到相同 c c c。如果消息空间很小,例如“同意/拒绝”、四位验证码或固定状态码,攻击者可以枚举候选消息:

c i = m i e   m o d   n c_i=m_i^e\bmod n ci=miemodn

再与截获密文比较。攻击者不需要分解 n n n,也不需要恢复私钥。

现代公钥加密要求相同明文在不同加密中产生不可预测的不同密文。随机化编码不是装饰,而是安全定义的一部分。


2.2 乘法结构带来可塑性

若攻击者截获:
c = m e   m o d   n c=m^e\bmod n c=memodn
任选一个可逆整数 r r r,可构造:
c ′ = c ⋅ r e   m o d   n = m e ⋅ r e   m o d   n = ( m ⋅ r ) e   m o d   n \begin{aligned} c'&=c\cdot r^e\bmod n \\ &=m^e\cdot r^e\bmod n \\ &=(m\cdot r)^e\bmod n \end{aligned} c=cremodn=meremodn=(mr)emodn
解密结果为:
( c ′ ) d ≡ ( ( m ⋅ r ) e ) d ( m o d n ) ≡ ( m ⋅ r ) e d ( m o d n ) ≡ m ⋅ r ( m o d n ) \begin{aligned} (c')^d&\equiv \big((m\cdot r)^e\big)^d \pmod n \\ &\equiv (m\cdot r)^{ed} \pmod n \\ &\equiv m\cdot r \pmod n \end{aligned} (c)d((mr)e)d(modn)(mr)ed(modn)mr(modn)

说明:满足 e d ≡ 1 ( m o d φ ( n ) ) ed \equiv 1 \pmod{\varphi(n)} ed1(modφ(n)),因此 ( m r ) e d ≡ m r ( m o d n ) (mr)^{ed}\equiv mr \pmod{n} (mr)edmr(modn)
攻击者虽然未必知道 m m m,却能可控地改变明文。这说明裸 RSA 不具备抗篡改能力,也无法抵抗选择密文攻击。


2.3 直接对哈希做私钥 幂也不够

假设签名被定义为:

s = H ( M ) d   m o d   n s=H(M)^d\bmod n s=H(M)dmodn

这仍缺少严格编码与域分离,无法明确绑定哈希算法和格式,而且 RSA 的代数结构可能与不当哈希、截断或输入构造组合出伪造风险。

标准签名方案会先把摘要编码为结构化代表元,再执行 RSA 私钥原语。PKCS#1 v1.5 签名使用 DigestInfo,PSS 则使用随机盐与掩码结构。


三、OAEP:给 RSA 加密加入随机化编码

3.1 OAEP 的输入与长度边界

OAEP 全称 Optimal Asymmetric Encryption Padding。它不是在明文末尾简单补几个随机字节,而是使用哈希函数和 MGF1,把消息、标签摘要、填充区与随机种子双向掩码。

设:

  • k k k:RSA 模数按字节计的长度;
  • h L e n hLen hLen:哈希输出长度;
  • M M M:待加密消息;
  • L L L:可选标签,通常为空字节串;
  • MGF:掩码生成函数,通常是 MGF1。

消息长度必须满足:

∣ M ∣ ≤ k − 2 h L e n − 2 |M|\le k-2hLen-2 Mk2hLen2

例如 RSA-2048 的 k = 256 k=256 k=256,使用 SHA-256 时 h L e n = 32 hLen=32 hLen=32,最大消息长度为:

256 − 2 × 32 − 2 = 190  字节 256-2\times32-2=190\text{ 字节} 2562×322=190 字节

这再次说明 RSA-OAEP 不适合直接加密大文件。


3.2 先构造数据块 DB

首先计算标签摘要:

l H a s h = Hash ⁡ ( L ) lHash=\operatorname{Hash}(L) lHash=Hash(L)

再生成全零填充串 PS,使数据块长度恰好满足要求:

D B = l H a s h ∥ P S ∥ 01 ∥ M DB=lHash\parallel PS\parallel 01\parallel M DB=lHashPS01M

其中 ∥ \parallel 表示字节串连接,01 是单字节分隔符 0x01

若标签为空, l H a s h lHash lHash 仍然不是空串,而是空字节串的固定哈希值。解码端会重新计算它,用于确认标签和结构一致。


3.3 随机种子与两层掩码

随机生成长度为 h L e n hLen hLen 的种子:

s e e d ← $ { 0 , 1 } 8 h L e n seed\xleftarrow{\$}\{0,1\}^{8hLen} seed$ {0,1}8hLen

用种子生成与 DB 等长的掩码:

d b M a s k = MGF ⁡ ( s e e d , k − h L e n − 1 ) dbMask=\operatorname{MGF}(seed,k-hLen-1) dbMask=MGF(seed,khLen1)

m a s k e d D B = D B ⊕ d b M a s k maskedDB=DB\oplus dbMask maskedDB=DBdbMask

再反过来,用 maskedDB 生成种子掩码:

s e e d M a s k = MGF ⁡ ( m a s k e d D B , h L e n ) seedMask=\operatorname{MGF}(maskedDB,hLen) seedMask=MGF(maskedDB,hLen)

m a s k e d S e e d = s e e d ⊕ s e e d M a s k maskedSeed=seed\oplus seedMask maskedSeed=seedseedMask

最终编码消息为:

E M = 00 ∥ m a s k e d S e e d ∥ m a s k e d D B EM=00\parallel maskedSeed\parallel maskedDB EM=00maskedSeedmaskedDB

E M EM EM 转成整数 m m m 后,才执行:

c = m e   m o d   n c=m^e\bmod n c=memodn

同一消息每次都会使用新随机种子,因此通常得到不同的 EM 和不同密文。


3.4 MGF1 是怎样扩展掩码的

MGF1 接收一个种子和目标长度,把计数器从 0 0 0 开始编码为固定四字节,再不断计算:

T = Hash ⁡ ( s e e d ∥ C 0 ) ∥ Hash ⁡ ( s e e d ∥ C 1 ) ∥ ⋯ T=\operatorname{Hash}(seed\parallel C_0)\parallel \operatorname{Hash}(seed\parallel C_1)\parallel\cdots T=Hash(seedC0)Hash(seedC1)

最后截取需要的前若干字节:

m a s k = T [ 0 : m a s k L e n ] mask=T[0:maskLen] mask=T[0:maskLen]

MGF1 不是新的加密算法,而是利用哈希函数把短输入确定性扩展成指定长度的伪随机外观掩码。


3.5 OAEP 解码不能泄露失败细节

解密端先做 RSA 私钥运算得到 EM,然后按相反顺序恢复 seedDB,检查:

  • 首字节是否为 0x00
  • 恢复出的 l H a s h lHash lHash 是否正确;
  • 填充区是否由零字节组成;
  • 是否存在合法的 0x01 分隔符。

如果应用对不同错误返回不同消息、状态码或耗时,攻击者可能把解密接口变成预言机。因此 OAEP 解码应使用成熟库,并对外统一报告失败。


四、RSAES-PKCS1-v1_5 为什么仍需认识

4.1 旧式加密块结构

PKCS#1 v1.5 加密编码大致为:

EM = 00 || 02 || PS || 00 || M

其中 PS 至少 8 字节,并且由非零随机字节组成。

它曾被大量协议采用,兼容性很强,但对解码错误极其敏感。只要服务器能够让攻击者判断“填充格式是否正确”,就可能形成经典的 Bleichenbacher 填充预言机,逐步恢复明文。


4.2 不能把旧方案简单理解成“已经完全不可用”

历史协议和兼容系统中仍可能存在 PKCS#1 v1.5。风险判断取决于:

  • 是否存在攻击者可查询的解密接口;
  • 错误消息、连接行为和时间是否可区分;
  • 协议是否有额外随机秘密与降级保护;
  • 实现是否采用统一失败和专门缓解措施。

对于新设计,应优先使用经过规范配置的 RSA-OAEP;对于旧系统,不能只改一个算法名称,还要审查完整错误处理路径。


五、PSS:面向 RSA 签名的概率编码

5.1 为什么签名通常先哈希

消息可能非常长,而 RSA 原语只处理固定范围内的整数。签名方案先计算:

m H a s h = Hash ⁡ ( M ) mHash=\operatorname{Hash}(M) mHash=Hash(M)

但 PSS 并不是简单签署 m H a s h mHash mHash。它还加入随机盐,并构造一个具有明确边界和可验证结构的编码消息。


5.2 构造带盐摘要

生成长度为 s L e n sLen sLen 的随机盐:

s a l t ← $ { 0 , 1 } 8 s L e n salt\xleftarrow{\$}\{0,1\}^{8sLen} salt$ {0,1}8sLen

构造:

M ′ = 00   00   00   00   00   00   00   00 ∥ m H a s h ∥ s a l t M'=00\ 00\ 00\ 00\ 00\ 00\ 00\ 00\parallel mHash\parallel salt M=00 00 00 00 00 00 00 00mHashsalt

也就是 8 个零字节、消息摘要和盐的连接。再计算:

H = Hash ⁡ ( M ′ ) H=\operatorname{Hash}(M') H=Hash(M)

盐使同一消息在多次签名时通常产生不同签名,同时也是 PSS 安全证明结构的一部分。


5.3 构造并掩码数据块

先构造:

D B = P S ∥ 01 ∥ s a l t DB=PS\parallel01\parallel salt DB=PS01salt

其中 PS 是适当长度的全零串。

生成掩码:

d b M a s k = MGF ⁡ ( H , e m L e n − h L e n − 1 ) dbMask=\operatorname{MGF}(H,emLen-hLen-1) dbMask=MGF(H,emLenhLen1)

m a s k e d D B = D B ⊕ d b M a s k maskedDB=DB\oplus dbMask maskedDB=DBdbMask

为了让编码整数落入允许范围,还要把 maskedDB 最高端超出 emBits 的若干比特清零。最终:

E M = m a s k e d D B ∥ H ∥ B C EM=maskedDB\parallel H\parallel BC EM=maskedDBHBC

末尾 BC 表示单字节 0xbc

签名者将 E M EM EM 转为整数 m m m,执行:

s = m d   m o d   n s=m^d\bmod n s=mdmodn


5.4 PSS 验证流程

验证者对签名执行公钥运算:

m = s e   m o d   n m=s^e\bmod n m=semodn

m m m 转回固定长度的 EM,然后检查:

  1. 末尾是否为 0xbc
  2. 最高未使用比特是否为零;
  3. 通过 MGF1 恢复 DB
  4. DB 是否符合 PS || 01 || salt
  5. 重新计算消息摘要和 H ′ H' H
  6. 比较 H ′ H' H 与编码中的 H H H

只有全部条件同时满足,签名才有效。


5.5 盐长度必须由协议明确

常见选择是让:

s L e n = h L e n sLen=hLen sLen=hLen

但标准和不同 API 也可能支持其他长度,甚至使用尽可能长的盐。签名方和验证方必须就哈希算法、MGF1 哈希和盐长度达成一致。

“都叫 RSA-PSS”并不保证参数完全一致。协议字段、证书算法标识和库配置必须明确表达这些参数。


六、PSS 与 PKCS#1 v1.5 签名的区别

6.1 v1.5 签名是确定性编码

RSASSA-PKCS1-v1_5 的编码大致为:

EM = 00 || 01 || FF...FF || 00 || DigestInfo

DigestInfo 中包含哈希算法标识和摘要。只要消息、密钥与哈希算法相同,签名结果也相同。

它与曾遭受填充预言机攻击的“v1.5 加密”不是同一个编码结构,不能仅凭名称把两者风险混为一谈。规范实现的 v1.5 签名仍广泛存在,但新协议通常更倾向 PSS。


6.2 为什么解析必须严格

历史上有些验证器没有检查完整 FF 填充、长度或尾随垃圾,只在解码结果中“找到了一个摘要”就判定成功。配合小公钥指数,宽松解析可能形成签名伪造。

安全验证应要求:

  • 编码长度完全匹配;
  • 每个固定字节和分隔符正确;
  • 算法标识正确;
  • 不接受尾随数据;
  • 比较操作避免不必要的侧信道。

密码格式的验证原则是“只接受唯一合法编码”,而不是“尽量从错误输入里猜出有效内容”。


七、实际系统怎样使用 RSA

7.1 混合加密而不是分块 RSA

正确思路通常是:

  1. 随机生成对称会话密钥 K K K
  2. 使用 RSA-OAEP 加密 K K K
  3. 使用 AES-GCM 或 ChaCha20-Poly1305 加密业务数据;
  4. 把封装后的密钥、nonce、密文和认证标签按协议发送。

不要把大文件切成许多小块,再逐块执行 RSA。那样不仅性能极差,还很容易破坏消息顺序、完整性和协议安全。


7.2 加密与签名最好分离密钥用途

即使某些格式允许同一 RSA 密钥执行多种操作,也应通过证书 Key Usage、密钥管理策略和 API 权限限制用途。分离密钥可以减少跨协议攻击面,也便于轮换、审计和事故处置。


7.3 Python 库验证示例

以下示例展示 OAEP 和 PSS 的职责差异,需要安装 PyCryptodome:

from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_OAEP
from Crypto.Signature import pss
from Crypto.Hash import SHA256

key = RSA.generate(2048)
public_key = key.publickey()
message = b"RSA uses different schemes for encryption and signature."

# OAEP:使用公钥加密、私钥解密
cipher_enc = PKCS1_OAEP.new(public_key, hashAlgo=SHA256)
ciphertext = cipher_enc.encrypt(message)

cipher_dec = PKCS1_OAEP.new(key, hashAlgo=SHA256)
recovered = cipher_dec.decrypt(ciphertext)
print("OAEP recovered:", recovered)

# PSS:对消息摘要签名,再使用公钥验证
message_hash = SHA256.new(message)
signature = pss.new(key).sign(message_hash)

verify_hash = SHA256.new(message)
pss.new(public_key).verify(verify_hash, signature)
print("PSS signature valid")

示例用于展示标准库接口,不包含完整的密钥存储、证书验证和协议封装。生产系统还需根据所用协议固定参数,并保护私钥和错误处理路径。


八、常见误区与工程检查表

8.1 “公钥能解开的就是私钥持有者发的”

只有当输入经过严格签名编码并通过完整验证时,才能得到签名意义。任意私钥模幂结果不自动成为安全签名。

8.2 “OAEP 已经提供消息来源认证”

OAEP 的主要目标是公钥加密安全。它不等同于发送者数字签名。任何知道接收方公钥的人都可以生成 OAEP 密文。

8.3 “RSA-2048 可以加密 256 字节”

256 字节是模数长度,不是 OAEP 可用明文长度。必须扣除编码开销:

m L e n ≤ k − 2 h L e n − 2 mLen\le k-2hLen-2 mLenk2hLen2

8.4 “只要用了库默认值就一定互通”

至少要明确:

  • OAEP 主哈希算法;
  • MGF1 使用的哈希算法;
  • OAEP 标签;
  • PSS 消息哈希;
  • PSS 的 MGF1 哈希;
  • PSS 盐长度;
  • RSA 密钥长度与允许用途。

不同平台默认值可能不同,尤其是旧 API 可能默认 SHA-1 或 PKCS#1 v1.5。


九、总结

RSA 数学原语只提供固定模数下的可逆指数变换,完整加密与签名必须由标准编码方案补上安全语义:

  • 裸 RSA 确定、可塑,不能直接加密业务消息;
  • OAEP 使用标签摘要、随机种子和 MGF1 构造随机化加密编码;
  • RSA 加密通常只用于封装短小的对称密钥;
  • 数字签名不是“私钥加密原消息”;
  • PSS 对消息摘要、随机盐和掩码结构进行严格编码;
  • 验证器必须完整检查格式,解密端不能泄露细粒度填充错误;
  • 新系统优先选择协议明确支持的 OAEP 与 PSS,并固定全部参数。

下一篇将暂时从工程规范切换到竞赛与错误配置场景,分析小指数、共模、广播、Wiener 和 CRT 等攻击为什么成立。重点不是背脚本,而是识别每种攻击所依赖的前提:究竟是哪一项 RSA 安全条件被破坏了。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐