RSA 同时出现在"加密接口"和"签名接口"中,最容易被误用的地方,是把它们当成同一个操作的正反方向。本篇用一个能手算的小例子解释数学,再用真实密码库验证 OAEP 加密、PSS 签名与失败条件。
先确定需要保护什么
把报告发给接收者,希望只有对方能读:使用接收者公钥加密,对方私钥解密。发布报告,希望读者能发现改动并验证签发者:使用签发者私钥签名,读者用可信的签发者公钥验签。签名不隐藏正文,加密本身也不证明发送者身份。
RSA 是公钥密码体系;完整的安全方案还包含编码、随机化和检查规则。本文加密用 RSA-OAEP,签名用 RSA-PSS。它们都在 RFC 8017 中定义,不能把教科书中的模幂直接当作线上协议。
从 p、q 到一对密钥
先取不同素数 p、q,计算 n=pq。在教学例中,用 φ(n)=(p−1)(q−1),选择与 φ(n) 互素的 e,再计算满足 ed≡1 mod φ(n) 的 d。公钥包含 n、e;私钥包含 d,实际格式通常也保存 p、q 及 CRT 加速参数。
取 p=61、q=53,就得到 n=3233、φ(n)=3120;选 e=17,得到 d=2753。明文先表示为 0 到 n−1 之间的整数 m,教学加密 c=m^e mod n,恢复 m=c^d mod n。m=65 时,c=2790,恢复后仍是 65。后面的脚本会独立检查这组数。
为什么能恢复?对不被 p 整除的 m,费马小定理给出 m^(p−1)≡1 mod p;ed−1 是 p−1 的倍数,所以 m^(ed)≡m mod p。若 m 被 p 整除,两边都为 0。对 q 同理,再由中国剩余定理得到模 n 的结果。这个论证解释正确性,不是安全性证明。
小素数可轻易分解,裸 RSA 还是确定性的;同样输入得到同样输出,并保留可被利用的代数结构。例子只用于理解,实际密钥生成和运算交给成熟密码库。不要自己挑固定素数,也不要把 d 打印进业务日志。
OAEP 为何有 190 字节限制
OAEP 在模幂之前对消息做带随机种子的编码,因此相同明文多次加密应得到不同密文,解密仍应恢复相同字节。解密方必须使用相同的摘要算法、MGF1 摘要与 label。
RFC 8017 给出的消息长度上限为 k−2×hLen−2,其中 k 是模数的字节长度,hLen 是摘要字节数。本例 RSA 2048 位对应 k=256,SHA-256 对应 hLen=32,于是上限为 190 字节。字符数不是字节数;UTF-8 中文通常占多个字节。密文本身固定为 256 字节,Base64 后长度又是另一层表示。
超出限制不要盲目切成多个 RSA 块。常见的混合加密思路是:随机生成会话密钥,用经过认证的对称加密方案保护正文,再用公钥方案保护短密钥。真正的文件格式还需要算法版本、nonce、认证标签、密钥标识和绑定上下文,优先采用已有协议或可靠封装;本篇没有实现完整文件加密产品。
PSS 验签不是"公钥解密正文"
PSS 将消息摘要和随机盐编码,再用私钥产生签名。验证方输入的是消息、签名和公钥,结果是成功或失败,不是恢复一份隐藏的正文。示例明确采用 SHA-256、MGF1-SHA-256、32 字节盐。对接时如果一方用最大盐长、另一方限定 32 字节,就需要统一协议约定。
cryptography 50.0.1 的 RSA 文档 将签名、验证、加密和解密列为独立接口。本实验特意改动正文并换用另一把公钥,验证错误不是被吞掉的。
完整实验:复制后可以直接运行
2026-09-28 实际环境为 macOS 27.2 arm64、Python 3.13.13、cryptography 50.0.1。若读者环境缺少依赖,可在隔离的 Python 环境安装 cryptography;把代码保存为 rsa_demo.py 后执行 python3 rsa_demo.py。这里生成的都是临时演示密钥,仅在内存中使用,没有读取任何真实账户密钥。
python
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes
from cryptography.exceptions import InvalidSignature
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
pub = key.public_key()
def oaep(label=None):
return padding.OAEP(mgf=padding.MGF1(hashes.SHA256()),
algorithm=hashes.SHA256(), label=label)
def must_fail(error, action):
try:
action()
except error:
return True
raise AssertionError('unexpected success')
message = '小静 RSA 实验'.encode('utf-8')
c1 = pub.encrypt(message, oaep())
c2 = pub.encrypt(message, oaep())
assert key.decrypt(c1, oaep()) == message
assert c1 != c2
assert len(c1) == 256
print('roundtrip=True randomized=True ciphertext_bytes=256')
assert key.decrypt(pub.encrypt(b'x' * 190, oaep()), oaep()) == b'x' * 190
must_fail(ValueError, lambda: pub.encrypt(b'x' * 191, oaep()))
must_fail(ValueError, lambda: key.decrypt(c1, oaep(b'wrong-label')))
other = rsa.generate_private_key(public_exponent=65537, key_size=2048)
must_fail(ValueError, lambda: other.decrypt(c1, oaep()))
print('190_bytes=OK 191_bytes=REJECT wrong_label=REJECT wrong_key=REJECT')
pss = padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=32)
sig = key.sign(message, pss, hashes.SHA256())
pub.verify(sig, message, pss, hashes.SHA256())
must_fail(InvalidSignature, lambda: pub.verify(sig, message+b'!', pss, hashes.SHA256()))
must_fail(InvalidSignature, lambda: other.public_key().verify(sig, message, pss, hashes.SHA256()))
print('signature=OK tampered_message=REJECT wrong_signer=REJECT')
# Teaching only: tiny textbook RSA has no security.
p, q, e = 61, 53, 17
n = p*q
d = pow(e, -1, (p-1)*(q-1))
c = pow(65, e, n)
assert (n, d, c, pow(c,d,n)) == (3233,2753,2790,65)
print('toy: n=3233 d=2753 m=65 c=2790 recovered=65')
本次实际输出:
text
roundtrip=True randomized=True ciphertext_bytes=256
190_bytes=OK 191_bytes=REJECT wrong_label=REJECT wrong_key=REJECT
signature=OK tampered_message=REJECT wrong_signer=REJECT
toy: n=3233 d=2753 m=65 c=2790 recovered=65
结果说明:正常回环和验签通过;两次密文不同;190 字节边界通过;191 字节、错误 label、错误解密私钥、被改动的消息和错误签名公钥均被拒绝。这是功能与参数边界验证,不能替代密码库审计,也没有做吞吐量测试。
对接失败时,按数据流核对
先核对公钥来源和指纹,随后核对算法完整名称与参数,再检查原始字节,最后检查编码封装。PEM 是带头尾标记的文本容器,DER 是二进制结构;"PUBLIC KEY"与"RSA PUBLIC KEY"可能对应不同结构,不应直接裁掉头尾就把内容当裸模数。
JSON 的字段顺序、空格、浮点序列化和 Unicode 表示都会影响待签名字节。应约定规范化方式或直接签协议规定的原始字节。Base64 与 Base64url 的字符表、补齐规则也需一致;不要先 URL 解码两次再验签。
实验为了易读,让同一对 RSA 密钥展示两个 API;实际应用按协议进行用途隔离、权限控制和轮换。接收者公钥也必须可信,攻击者替换公钥时,"加密成功"没有意义。将底层细节记录在受控诊断中,对外避免暴露可区分的解密失败原因。
读完后可以做一个小验收:不用看代码,分别写出加密和签名流程的参与者、使用哪一方的钥匙、输入输出及失败条件。如果还把签名输出叫"加密后的原文",就值得再回到两类接口的区别。
本文使用 AI 辅助整理与写作,代码和列出的输出已在文中环境实际运行;未进行生产环境、跨厂商或安全认证测试。