RSA-OAEP 解密失败的参数一致性排查
一、先看报错是什么
同一个 RSA-OAEP 密文,换个栈解就挂掉,各家抛出来的字符串不一样:
- OpenSSL 3.x / PHP / C 扩展:
error:0200009F:rsa routines:RSA_padding_check_PKCS1_OAEP_mgf1:oaep decoding error - Go:
crypto/rsa: decryption error(rsa.ErrDecryption),标准库故意不区分具体原因 - Java:
javax.crypto.BadPaddingException: Decryption error,或者明文过长时的javax.crypto.BadPaddingException: message too long - Node:
error:0200009F:rsa routines::oaep decoding error/pkcs decoding error
这几条都属于「填充校验没过」,不是密钥读错了,也不是网络截断。密钥读错通常在更早的阶段就失败了(解析 PEM 直接报错)。因此要查的是参数是否对齐,而不是密码学本身。
补一条环境问题:OpenSSL 里 RSA_padding_check_PKCS1_OAEP_mgf1 在 FIPS provider 下不往错误栈压错误码,有的环境只能拿到 BadPaddingException 一类泛化异常,连 oaep decoding error 都看不到。
二、OAEP 编码了什么(RFC 8017 §7.1.1)
记 k = 模长字节数(RSA-2048 即 256),hLen = 摘要输出字节数(SHA-1 = 20,SHA-256 = 32)。
加密(EME-OAEP-ENCODE):
lHash = Hash(L),L是 label(encoding parameters),可空;空 label 就是 hash 空串DB = lHash || PS || 0x01 || M,PS是全零填充,长度让整体正好填满- 随机取
seed,长度hLen dbMask = MGF1(seed, k - hLen - 1);maskedDB = DB XOR dbMaskseedMask = MGF1(maskedDB, hLen);maskedSeed = seed XOR seedMaskEM = 0x00 || maskedSeed || maskedDB,长度k- 再对
EM做 RSA 公钥运算,得到k字节密文
解密(EME-OAEP-DECRYPT)反向走一遍,最后校验三件事:首字节是 0x00、DB 前 hLen 字节等于 lHash、DB 里存在 0x01 分隔符。任一条不符 → decryption error。
MGF1(Z, l):对 counter = 0, 1, 2... 计算 Hash(Z || I2OSP(counter, 4)) 并拼接,取前 l 字节。
这里有一个关键的实现事实:标准里 Hash(算 lHash)和 MGF1 内部用的 Hash 是两个可独立选择的参数。OpenSSL 的函数签名就是把它们分开的:
rsa_padding_add_PKCS1_OAEP_mgf1(..., md, mgf1md)
而 Go 标准库 rsa.EncryptOAEP 只有一个 hash 参数,lHash 和 MGF1 必须同一种摘要。所以「Go 和 Java 默认能互通」这个假设不成立,能不能通取决于 Java 侧 MGF1 用的是什么。
三、四项参数的逐项排查
1. Hash(lHash 用的摘要)
默认值各家不统一。OpenSSL 的 RSA_PKCS1_OAEP_PADDING(PHP 的 openssl_* 用的就是它)文档写死为 "SHA-1, MGF1 and an empty encoding parameter";Go 由调用者传;Node 的 oaepHash 默认 'sha1';Java 的算法串 RSA/ECB/OAEPWithSHA-256AndMGF1Padding 里写明了 SHA-256。
2. MGF1 的摘要
这是最常见的坑。Java 用 OAEPWithSHA-256AndMGF1Padding 这个串时,SunJCE 历史上 MGF1 用 SHA-1,只有 lHash 用 SHA-256。要和 Go / OpenSSL(mgf1_md=sha256)对齐,必须显式传:
new OAEPParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, PSpecified.DEFAULT)
各 JDK 版本是否还保持这个行为,以自己环境实测为准——写个自环测试跑一遍确认,别赌。
OpenSSL 命令行要同时给两个参数:
-pkeyopt rsa_oaep_md:sha256 -pkeyopt rsa_mgf1_md:sha256
只给 rsa_oaep_md 而漏掉 rsa_mgf1_md,MGF1 仍然走默认,照样解不开。
3. label(encoding parameters)
Go 的 label 参数、Java 的 PSpecified、OpenSSL 命令行没有直接暴露(默认空)。空 label 和 nil 等价(都是 hash 空串),但只要一方传了非空 label(比如商户号、协议版本号),另一方用空,必然 decryption error,而且表现跟密钥不匹配一模一样。收到别人给的密文时,先问清 label 传的什么。
4. 模长 / 密文长度
密文长度必须等于 k。base64 解出来 244 字节而不是 256,多半是把 PKCS#1 v1.5 的明文上限 245 记混了,或者 base64 里混进了 URL-safe 字符、换行。
另外公钥和私钥不是一对(多环境配置串了)也会落到同一条报错上。先比一下模数指纹:
openssl rsa -pubin -in pub.pem -modulus -noout | openssl md5
openssl rsa -in priv.pem -modulus -noout | openssl md5
两个 md5 不一致,后面所有参数排查都是白费。
默认值对照表
| 栈 | lHash 摘要 | MGF1 摘要 | label |
|---|---|---|---|
OpenSSL RSA_PKCS1_OAEP_PADDING(PHP openssl_*) | SHA-1 | SHA-1 | 空 |
Go rsa.EncryptOAEP | 调用者传的 hash | 同一个 hash | 调用者传,可为 nil |
Node oaepHash | oaepHash(默认 'sha1') | 同一个 | 默认空 |
Java RSA/ECB/OAEPWithSHA-256AndMGF1Padding | SHA-256 | SunJCE 历史上 SHA-1,以自己环境实测为准 | PSpecified.DEFAULT(空) |
openssl pkeyutl | rsa_oaep_md | rsa_mgf1_md | 命令行不暴露,空 |
明文长度上限
上限是 k - 2*hLen - 2:
| 密钥 + 摘要 | 可用字节 |
|---|---|
| RSA-2048 + SHA-1 | 256 - 40 - 2 = 214 |
| RSA-2048 + SHA-256 | 256 - 64 - 2 = 190 |
| RSA-4096 + SHA-256 | 512 - 64 - 2 = 446 |
| RSA-1024 + SHA-256 | 128 - 64 - 2 = 62 |
超了在加密侧就会报 RSA_R_DATA_TOO_LARGE_FOR_KEY_SIZE,或者 Go 的 message too long for RSA key size,别等到解密侧才查。RSA-1024 配 SHA-256 只剩 62 字节,连一个 AES-256 密钥加头部都不宽裕,该换密钥长度了。
四、同一份参数在五种栈里怎么写
Go:
hash := sha256.New()
ct, err := rsa.EncryptOAEP(hash, rand.Reader, pub, msg, nil)
pt, err := rsa.DecryptOAEP(sha256.New(), nil, priv, ct, nil)
解密侧的 random 可以传 nil(Go 1.20 之后不再需要盲化参数)。注意 Go 只有一个 hash 参数,lHash 和 MGF1 绑死,没有分开指定的余地。
PHP(openssl 扩展):只能用 SHA-1 + 空 label,无参数可调:
openssl_public_encrypt($msg, $ct, $pubPem, OPENSSL_PKCS1_OAEP_PADDING);
openssl_private_decrypt($ct, $pt, $privPem, OPENSSL_PKCS1_OAEP_PADDING);
要对齐 SHA-256 或自定义 label,换 phpseclib:
$rsa = new \phpseclib3\Crypt\RSA();
$rsa->loadKey($priv);
$rsa->setHash('sha256');
$rsa->setMGFHash('sha256');
$plain = $rsa->decrypt($ct);
setHash 和 setMGFHash 是两个独立调用,正好对应标准里的两个参数。
Node:
crypto.publicEncrypt({
key,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256'
}, buf)
oaepHash 同时作用于 lHash 和 MGF1,无法分开指定——这一点和 Go 相同,和 Java / OpenSSL 不同。
Java(解密侧显式指定,不要依赖算法串默认):
Cipher c = Cipher.getInstance("RSA/ECB/OAEPPadding");
OAEPParameterSpec spec = new OAEPParameterSpec(
"SHA-256", "MGF1", MGF1ParameterSpec.SHA256, PSpecified.DEFAULT);
c.init(Cipher.DECRYPT_MODE, priv, spec);
用 RSA/ECB/OAEPPadding 而不是带 WithSHA-256 的串,是为了让四个参数全部显式落地,避免算法串隐含的 MGF1 默认值把人坑一次。
openssl 命令:
openssl pkeyutl -decrypt -inkey priv.pem -in ct.bin -out pt.bin \
-pkeyopt rsa_padding_mode:oaep \
-pkeyopt rsa_oaep_md:sha256 \
-pkeyopt rsa_mgf1_md:sha256
五、实际排查顺序
- 自环:用同一份密钥、同一段代码先加密再解密,跑通说明本端参数自洽,问题在对端。
- 换端只改一端:把密文丢给
openssl pkeyutl,试 sha1 / sha256 × mgf1 sha1 / mgf1 sha256 四种组合,能解出来的那组就是对方的参数。四组都解不出,回到密钥对、label、密文编码三项。 - 定死协议:把 hash、MGF1 hash、label 写进接口文档。别写「RSA 加密」,要写
RSA-OAEP-SHA256 / MGF1-SHA256 / label="order-v1"。 - 进 CI:把自环测试挂到流水线上,升级 OpenSSL 或 JDK 之后先跑它。
六、错误信息必须收敛成 decryption error
填充失败的细节不能抛给调用方。「首字节非零」「lHash 不匹配」「找不到 0x01」这几种错误,对外的表现必须完全一致:同样的错误码、同样的耗时。否则就是 Manger 2001 那类选择密文攻击的入口——攻击者能靠错误类型和响应时间逐字节地把明文试出来。
Go 的 rsa.ErrDecryption 只给一个笼统字符串,OpenSSL 的解码实现是常量时间的,这些都是有意为之。自己手写 OAEP 解析(或者为了「排查方便」把失败原因写进日志返回给上游)反而容易破坏这一点。
七、RSA-OAEP 只应该包对称密钥
不要用 RSA 直接加密业务报文。2048 位 + SHA-256 只有 190 字节可用,稍微带点 JSON 结构就超;而且每次加密都有随机 seed,同一明文两次加密的密文不同,不可比较、不可索引、不可做去重。
正确做法是信封加密:RSA-OAEP 只包一个对称密钥,正文用 AES-256-GCM 加密,nonce 和 tag 跟密文一起传。RSA 承担密钥封装,AES 承担数据加密,各自干自己擅长的事。
RSA-OAEP 的报错几乎从不指向「算法不安全」,而是指向「两边参数没对齐」:hash、MGF1 的 hash、label、k。把这四项写成一张表贴到接口文档里,再谈代码。