RSA-PSS 盐长度工程实战:MAX_LENGTH 的真实长度与最小安全值
前言
RSA-PSS(RSASSA-PSS)是 RSA 签名中唯一具有紧致安全证明的方案,而 salt_length 是它最容易踩坑的参数。很多工程师以为"盐越长越安全、MAX_LENGTH 就是最大安全值",甚至把 MAX_LENGTH 记成 hash_len - 2(SHA-256 下 30 字节)。
这个理解是错的。本文基于 RFC 8017、FIPS 186-5 原文和 cryptography 48.0 的实测,把三件事讲清楚:
MAX_LENGTH到底是多少字节,它随什么变化;- 盐长度和安全强度的真实关系(没有"越大越安全"这回事);
- 各语言库的默认盐长度为何不一致,跨语言对接怎么不出事。
一、MAX_LENGTH 不是 30 字节,是哨兵对象
1.1 它解析成多少
padding.PSS.MAX_LENGTH 不是一个数字,而是 cryptography 库的哨兵对象(_MaxLength)。它的具体取值在签名/验签时才解析,等于 RFC 8017 允许的上限 emLen - hLen - 2:
emLen= RSA 模数长度(字节)。2048 位密钥 = 256 字节,3072 位 = 384 字节hLen= 哈希输出长度。SHA-256 = 32 字节
from cryptography.hazmat.primitives.asymmetric import padding, rsa
from cryptography.hazmat.primitives import hashes
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
print(type(padding.PSS.MAX_LENGTH))
# <class '...padding._MaxLength'> <- 哨兵对象,不是 int
print(padding.PSS.DIGEST_LENGTH) # SHA-256 的哈希输出长度 = 32
# 解析上限:emLen - hLen - 2
em_len = 256 # 2048 位密钥
h_len = 32 # SHA-256
print(em_len - h_len - 2) # 222实测验证(cryptography 48.0,2048 位密钥):
sig = key.sign(b"message",
padding.PSS(mgf=padding.MGF1(hashes.SHA256()),
salt_length=padding.PSS.MAX_LENGTH),
hashes.SHA256())
for s in range(223):
try:
key.public_key().verify(
sig, b"message",
padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=s),
hashes.SHA256())
print(f"实际盐长度 = {s}") # 222
break
except Exception:
continue运行输出:>222。MAX_LENGTH 签出的签名只有 salt=222 能通过验证;salt=30、salt=32 全部报InvalidSignature。
常见错误:有人把 MAX_LENGTH 当成 30 字节(hLen-2)去写互操作协议,结果对端按 222 验证直接失败。30 = hLen-2,而 MAX_LENGTH = emLen-hLen-2,两者相差 emLen-2×hLen(2048 位 + SHA-256 下 192 字节)。
1.2 为什么 30 字节是错的,48 字节反而是合法的
hash_len - 2 = 30 这个值来自对 RFC 编码步骤的误读。真实的约束是:
- RFC 8017(9.1.1 第 3 步):
emLen < hLen + sLen + 2时报 encoding error。即sLen ≤ emLen - hLen - 2,2048 位 + SHA-256 下上限是 222 字节 - FIPS 186-5(5.4(g)):
0 ≤ sLen ≤ hLen,即 FIPS 场景下上限收紧为 32 字节(SHA-256)
# 48 字节盐:完全合法
sig = key.sign(b"m",
padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=48),
hashes.SHA256())
print(len(sig)) # 256
# 223 字节:超过 emLen-hLen-2,库拒绝
try:
key.sign(b"m",
padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=223),
hashes.SHA256())
except ValueError as e:
print("223 字节:", e) # salt length too large
# sLen = 0:合法,PSS 退化为确定性签名(Full Domain Hashing)
sig0 = key.sign(b"m",
padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=0),
hashes.SHA256())
print("sLen=0 签名长度:", len(sig0)) # 256注意:sLen=0 是 RFC 8017 明确允许的。但 FIPS 186-5 要求 0 ≤ sLen ≤ hLen,也就是说 0 合法;真正"违反 FIPS"的是 sLen > hLen(如 48、222 字节)。很多文档说"短盐违反 FIPS 最低要求",方向正好反了——是长盐违反 FIPS。
二、三种取值模式与安全性的真实关系
2.1 三种模式对照
| 模式 | SHA-256 / 2048 位下取值 | 依据 |
|---|---|---|
MAX_LENGTH(哨兵) | 222(= emLen-hLen-2) | RFC 8017 上限 |
DIGEST_LENGTH(哨兵) | 32(= hLen) | FIPS 186-5 常用值 |
| 自定义(如 0/16/32) | 0 ≤ sLen ≤ 32(FIPS)或 ≤ 222(纯 RFC) | 场景需求 |
2.2 盐长度 ≠ 安全强度
这是本文最重要的一个澄清。PSS 的安全性证明(Bellare-Rogaway,紧致归约)建立在 RSA 问题(inverting RSAVP1)的困难性上,归约本身不要求盐达到某个"最大"值。
RFC 8017 第 8.1 节原文说得很直接:
"Typical salt lengths in octets are hLen ... and 0. In both cases, the security of RSASSA-PSS can be closely related to the hardness of inverting RSAVP1."Coron 在 Eurocrypt 2002(*Optimal Security Proofs for PSS and Other Signature Schemes*)进一步给出紧致归约的量化结论:
"For an application in which at most one billion signatures will be generated, k0 = 30 bits of random salt are actually sufficient to guarantee the same level of security as RSA, and taking a larger salt does not increase the security level."翻译成工程语言:
- 签名安全强度主要取决于 RSA 密钥长度 和 哈希函数强度,而不是盐的长度
- 盐的作用是随机化——同一消息每次签名产生不同签名值,使攻击无法把多次签名关联起来。盐的随机性质量(必须来自 CSPRNG)比它的长度重要得多
- "盐越长攻击难度呈指数级增长"是错误表述。超过 30 bit 之后,增加盐长不增加安全强度
sLen=0(确定性)仍有可证明的安全性(归约到 FDH),只是失去了随机性带来的实现层面好处(如对故障注入的容忍)
- 合规:目标标准允许什么范围(FIPS 186-5:
0 ≤ sLen ≤ hLen) - 互操作:对接方默认用多长的盐
- 随机性:盐值是否每次由 CSPRNG 生成
2.3 盐长度不影响签名大小
PSS 签名长度恒等于 RSA 模数字节数(2048 位 = 256 字节),与盐长无关。盐长只改变内部编码中 PS(零填充段)的长度。这点常被误读为"盐越长签名越大"——对,错的。
三、跨语言默认值差异(实测与文档核对)
各库默认值不同,是生产环境验签失败的第一大原因。注意:这里列的是"默认"行为,不同版本有差异,对接前务必按目标版本实测。
| 库 | 默认盐长度(SHA-256 / 2048 位) | 说明 |
|---|---|---|
| Python cryptography | MAX_LENGTH → 222 | 哨兵在签名时解析为 emLen-hLen-2 |
Go crypto/rsa(默认 opts) | PSSSaltLengthAuto → 222 | 签名取最大,验证自动探测 |
| Rust ring | hLen → 32 | 固定取哈希输出长度 |
Java PSSParameterSpec.DEFAULT | 20 | 旧默认(SHA-1 时代,JDK 19 起已废弃);现代 named algorithm(SHA256withRSA/PSS)→ 32(= hLen) |
OpenSSL 3.x CLI(-rsassa-pss) | 签名时 clamp 到 hLen → 32 | 3.0 起为符合 FIPS 186-4/5 的 sLen ≤ hLen 收紧了默认值 |
MAX_LENGTH(222 字节)签名,Go 端用默认 PSSSaltLengthAuto 验证——能成功(验证侧自动探测盐长)。但如果 Go 端显式指定 PSSSaltLengthEqualsHash(32 字节)验证,必然失败。反过来,Rust ring 签出的 32 字节盐签名,Python 端用默认 MAX_LENGTH 验证也失败。// 假设 digest := sha256.Sum256(msg)
// Go 的 VerifyPSS 第二个参数是 crypto.Hash 常量(sha256.SHA256),不是 hash 实例
err1 := rsa.VerifyPSS(pub, sha256.SHA256, digest, sig, &rsa.PSSOptions{
SaltLength: rsa.PSSSaltLengthAuto, // 成功:验证侧自动探测盐长
})
err2 := rsa.VerifyPSS(pub, sha256.SHA256, digest, sig, &rsa.PSSOptions{
SaltLength: rsa.PSSSaltLengthEqualsHash, // 失败:显式 32 字节与 222 不匹配
})自动探测盐长的实用函数(排查"签名看着对但验不过"时首选):
from cryptography.hazmat.primitives.asymmetric import padding, rsa
from cryptography.hazmat.primitives import hashes
def probe_salt_length(pub_key, msg, sig, candidates=None):
"""逐个候选盐长尝试验证,返回第一个成功的盐长。"""
if candidates is None:
candidates = [
padding.PSS.DIGEST_LENGTH, # 32(FIPS 常用值)
0, 16, 20, 48, # 常见显式值
padding.PSS.MAX_LENGTH, # 哨兵:222(2048 位)
]
for s in candidates:
try:
pub_key.verify(
sig, msg,
padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=s),
hashes.SHA256())
return s
except Exception:
continue
return None
# 演示:分别用 222 字节(MAX)和 32 字节(DIGEST)签名,再探测
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
msg = b"message"
sig_max = key.sign(msg,
padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH),
hashes.SHA256())
sig_32 = key.sign(msg,
padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=32),
hashes.SHA256())
print(probe_salt_length(key.public_key(), msg, sig_max)) # 222
print(probe_salt_length(key.public_key(), msg, sig_32)) # 32不要把"多盐长逐个尝试"当作生产验签逻辑。多盐试探只用于互操作排查;生产环境应把盐长作为协议参数显式约定并固定,验签时只用约定的那一个值。逐个试探等于放弃"盐长不匹配即拒绝"的检查,且可能被用来放大侧信道/验证开销。
四、生产环境最佳实践
4.1 选型决策
| 场景 | 推荐盐长 | 理由 |
|---|---|---|
| FIPS 186-5 合规 | DIGEST_LENGTH(32) | 标准明确 0 ≤ sLen ≤ hLen,32 字节是常用值 |
| 仅遵循 RFC 8017、追求最大兼容性 | MAX_LENGTH(222) | 验证侧自动探测时对任意盐长都接受 |
| 跨语言对接 | 显式写死一个值(建议 32) | 所有主流库都支持 32;协议文档中注明 |
| 确定性场景(可重放、便于离线复现) | 0 | 合法但失去随机性,需评估合规要求 |
4.2 推荐配置示例
# 与 FIPS 186-5 对齐:sLen = hLen
SIGNATURE_CONFIG = {
"hash": hashes.SHA256(),
"mgf": padding.MGF1(hashes.SHA256()),
"salt_length": 32, # = DIGEST_LENGTH(SHA-256)
}
def sign_message(key: rsa.RSAPrivateKey, message: bytes) -> bytes:
return key.sign(
message,
padding.PSS(mgf=SIGNATURE_CONFIG["mgf"],
salt_length=SIGNATURE_CONFIG["salt_length"]),
SIGNATURE_CONFIG["hash"])
def verify_message(pub_key: rsa.RSAPublicKey, message: bytes, sig: bytes) -> None:
# 验证时同样显式指定 32 字节;盐长不一致直接拒绝
pub_key.verify(
sig, message,
padding.PSS(mgf=SIGNATURE_CONFIG["mgf"],
salt_length=SIGNATURE_CONFIG["salt_length"]),
SIGNATURE_CONFIG["hash"])4.3 遗留系统兼容
对接老系统(salt 未知)时,用第 3 节的 probe_salt_length 做一次性排查,确定对端盐长后写死进协议。不要长期保留多盐试探。
4.4 性能
盐长对性能影响可忽略:主要成本在 RSA 模幂运算(O(密钥长度)),MGF1 生成和随机数生成都是 O(hLen) 级别。在 2048 位密钥下,sLen 从 0 到 222 的签名耗时差异远小于一次 RSA 运算本身的抖动。真正的性能考量是密钥长度和是否走 CRT,与盐长无关。
五、常见错误清单
- 把 MAX_LENGTH 当成 30 字节 —— 它是哨兵,解析为 emLen-hLen-2(2048+SHA-256 下 222 字节)
- 认为盐越长越安全 —— 超过 ~30 bit 后不再增加安全强度(Coron 2002);安全强度由密钥长度和哈希决定
- 认为短盐违反 FIPS —— 方向反了;FIPS 186-5 是上限
sLen ≤ hLen,0 字节合法,长于 hLen 才违规 - 跨语言对接不约定盐长 —— Python/Go 默认 222,Rust/Java 常用 32,不写死必然翻车
- 把多盐试探当生产验签 —— 只用于排查;生产应固定盐长并严格匹配
- 用固定/可预测的盐值 —— 盐必须每次由 CSPRNG 生成;用固定盐会失去随机化优势(
sLen=0是合法例外,但属有意为之的确定性方案)
六、国密对照
RSA-PSS 这套参数体系(emLen、hLen、sLen、trailer 0xBC)是 PKCS#1 独有的。SM2 数字签名没有"盐长"参数:SM2 签名的随机性来自每次独立生成的随机数 k(GM/T 0003.2-2012 第 5.2 节签名算法),没有可配置的 salt_length,也不存在"默认值"一说。
所以:
- 做国密合规改造时,RSA-PSS 签名点直接替换为 SM2 签名(GM/T 0009-2012 使用规范),不存在"选多长 SM2 盐"的问题
- 双算法并行期(RSA + SM2 混合验签),两侧的验签逻辑完全独立,不要复用 PSS 盐长探测代码
- SM2 侧真正要防的坑是随机数 k 的生成质量(每次独立、CSPRNG)——等价于 RSA-PSS 里"盐的随机性",但机制不同
国密场景下,凡是原来用 RSA-PSS 的签名点(证书签名、API 报文签名、国密 TLS 握手中的证书/消息签名),替换为 SM2 后不再有"盐长"这个参数;对接时只要确认对端按 GM/T 0003.2-2012 生成、按 GM/T 0009-2012 规范验签即可,不要照搬 PSS 的盐长约定。
参考来源
- RFC 8017 - PKCS #1 v2.2: RSA Cryptography Specifications — Section 8.1 (RSASSA-PSS)、Section 9.1 (EMSA-PSS)
- FIPS 186-5 - Digital Signature Standard (DSS), Section 5.4(g) — "the length (in bytes) of the salt (sLen) shall satisfy 0 ≤ sLen ≤ hLen"
- Coron 2002 - Optimal Security Proofs for PSS and Other Signature Schemes, EUROCRYPT 2002 (LNCS 2332, pp. 272–287) — 盐长紧致归约的量化结论
- cryptography 文档 - PSS padding —
MAX_LENGTH/DIGEST_LENGTH哨兵 - Go
crypto/rsaPSSOptions —PSSSaltLengthAuto/PSSSaltLengthEqualsHash - Java
PSSParameterSpec
相关实践
- RSA 公钥密码算法原理详解 — PSS 填充方案的理论基础
- RSA 密钥格式工程实战 — PKCS#1 与 PKCS#8 格式差异及 PSS 互操作