编程 RSA-OAEP 解密报 oaep decoding error:hash、MGF1、label 与模长四项不一致的排查顺序

2026-10-21 00:03:53

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):

  1. lHash = Hash(L),L 是 label(encoding parameters),可空;空 label 就是 hash 空串
  2. DB = lHash || PS || 0x01 || M,PS 是全零填充,长度让整体正好填满
  3. 随机取 seed,长度 hLen
  4. dbMask = MGF1(seed, k - hLen - 1);maskedDB = DB XOR dbMask
  5. seedMask = MGF1(maskedDB, hLen);maskedSeed = seed XOR seedMask
  6. EM = 0x00 || maskedSeed || maskedDB,长度 k
  7. 再对 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-1SHA-1空
Go rsa.EncryptOAEP调用者传的 hash同一个 hash调用者传,可为 nil
Node oaepHashoaepHash(默认 'sha1')同一个默认空
Java RSA/ECB/OAEPWithSHA-256AndMGF1PaddingSHA-256SunJCE 历史上 SHA-1,以自己环境实测为准PSpecified.DEFAULT(空)
openssl pkeyutlrsa_oaep_mdrsa_mgf1_md命令行不暴露,空

明文长度上限

上限是 k - 2*hLen - 2:

密钥 + 摘要可用字节
RSA-2048 + SHA-1256 - 40 - 2 = 214
RSA-2048 + SHA-256256 - 64 - 2 = 190
RSA-4096 + SHA-256512 - 64 - 2 = 446
RSA-1024 + SHA-256128 - 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

五、实际排查顺序

  1. 自环:用同一份密钥、同一段代码先加密再解密,跑通说明本端参数自洽,问题在对端。
  2. 换端只改一端:把密文丢给 openssl pkeyutl,试 sha1 / sha256 × mgf1 sha1 / mgf1 sha256 四种组合,能解出来的那组就是对方的参数。四组都解不出,回到密钥对、label、密文编码三项。
  3. 定死协议:把 hash、MGF1 hash、label 写进接口文档。别写「RSA 加密」,要写 RSA-OAEP-SHA256 / MGF1-SHA256 / label="order-v1"。
  4. 进 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。把这四项写成一张表贴到接口文档里,再谈代码。

推荐文章

程序员茄子在线接单