在做接口对接时,最让人头疼的事之一就是签名验签失败。明明算法写对了、公钥也加载了、Base64解码也没报错,可结果就是返回false。尤其是从老系统切换到新系统、或者和外部机构联调时,对方文档里写着“SHA256withRSA签名”,你按常规方式去验,却发现怎么都不对。更隐晦的情况是,有时候一把钥匙能验过,换一把又不行,让人怀疑人生。
这篇文章聊的就是Java环境下做SHA256withRSA/PSS验签时遇到的典型问题和解法。我假设你有一定的Java基础,知道Signature类大概是干嘛的,但可能还没系统接触过PSS填充的细节。如果你最近正好在做支付、开放平台接入、单点登录这类对接工作,或者准备重构签名模块,这篇文章应该能帮你少走很多弯路。
1. 先搞清楚你面对的到底是什么算法
很多人在验签失败时第一反应是“Java代码写错了”,但实际情况往往是连算法本身的定义都没完全对齐。我见过不少同事把“SHA256withRSA”和“PSS”当成两个并列的东西去理解,其实它们之间的关系更准确地说,是“摘要算法 + 填充方案 + 非对称加密”的组合。
1.1 签名和验签的本质过程
RSA签名不是把数据整体丢给私钥加密,而是先对原文做摘要,再把摘要和填充信息一起交给RSA私钥运算。验签的时候,公钥把拿到的签名解开,恢复出“摘要+填充”结构,再对比重新计算出来的摘要,一致才算通过。
用生活化的例子来形容,签名就像是给快递盒子贴防伪标签。SHA256负责给盒子里面的东西算出一串“指纹”,私钥拿着这串指纹盖章,公钥验章。PKCS#1 v1.5和PSS的差别,就在于这个章的样式——一种是固定的模板,一种每次都会掺入一点随机元素,让章更不容易被仿冒。
1.2 PKCS#1 v1.5和PSS到底差在哪
在Java里,你写Signature.getInstance("SHA256withRSA"),默认走的是PKCS#1 v1.5填充。这是老牌方案,兼容性极好,几乎所有语言和框架都支持。但它在数学上存在一些已知的弱点,比如在特定条件下,攻击者可以构造出某个消息使得验签通过,这在安全性要求高的场景里是不可接受的。
PSS(Probabilistic Signature Scheme)是更现代的替代方案。它的核心特点是:签名过程中会生成随机盐(salt),同一个消息每次签名产生的密文都不一样,但验签时都能正确通过。这种概率性让签名更难被伪造。所以在金融、政务、运营商这类对安全要求极高的行业里,越来越多的接口要求使用RSASSA-PSS。如果你做的是新项目对接,对方要求PSS其实是很正常的趋势。
1.3 Java中算法名称的对照关系
一个很常见的坑是:很多业务文档里写着“SHA256withRSA/PSS”,但Java标准库里的算法名称并不是这样。你需要用特定的名称去注册算法,否则会直接抛NoSuchAlgorithmException。
| 场景 | JDK 8+ 对应算法名称 | 说明 |
|---|---|---|
| 传统SHA256withRSA | SHA256withRSA | 使用PKCS#1 v1.5填充 |
| Java原生PSS | RSASSA-PSS | 标准PSS,需额外配置参数 |
| 别名(部分JDK) | SHA256withRSA/PSS | 部分JDK的独立算法名,不通用 |
| 第三方库BouncyCastle | SHA256withRSAandMGF1 | BC风格,配置方式不同 |
如果你使用的是Java 8以上,推荐直接用RSASSA-PSS,然后配套一个PSSParameterSpec参数对象。这条路径最正统、最不容易出幺蛾子。
1.4 为什么“文档说法”和“代码实现”对不上
在联调的时候,对方告诉你的可能只是一个笼统的“SHA256withRSA”,但他没提内部用的是v1.5还是PSS。这时候就需要你自己去判断。最简单的办法是,先把对方给的公钥和一组“原文+签名”样例跑一遍——用两种算法各验一次,看哪个能过。这个方法在后面的实操部分会展开讲,你先记住这个思路。
2. 验签之前,先把密钥这一步走稳
我踩过最深的坑,其实不在签名验证逻辑本身,而在密钥加载环节。Java里加载公钥的写法看着很简单,但背后涉及编码格式、算法标识、Base64处理等各种细节。任何一环错了,后续代码写得再漂亮也没用。
2.1 识别公钥的编码格式:X.509还是PKCS#1
对方给你的公钥,最常见的有两种格式:
X.509 SubjectPublicKeyInfo:也叫SPKI格式,Java原生KeyFactory可以直接用。字符串开头默认是MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A...。PKCS#1 RSAPublicKey:也叫PKCS#1格式,Java默认不认,开头通常是MIIBCgKCAQEA...,需要先转成SPKI才能用。
怎么快速判断是哪种?可以写个小方法,把Base64解码后的前几个字节打出来看一眼。更省事的办法是,直接用Java解析试试,抛InvalidKeySpecException就说明格式不对。
import java.security.KeyFactory; import java.security.PublicKey; import java.security.spec.X509EncodedKeySpec; import java.util.Base64; public class RsaKeyDemo { public static PublicKey loadPublicKey(String base64Key) throws Exception { byte[] keyBytes = Base64.getDecoder().decode(base64Key); // 如果是PKCS#1,这里就会抛异常,需要先做转换 X509EncodedKeySpec spec = new X509EncodedKeySpec(keyBytes); KeyFactory kf = KeyFactory.getInstance("RSA"); return kf.generatePublic(spec); } }如果是PKCS#1格式,最稳妥的办法是引入Bouncy Castle或者自己写一个转换工具类。实际项目中,我更推荐提前跟对方确认格式,而不是在代码里做各种兼容分支。因为一旦签名模块上线,出问题的排查成本远大于写代码的成本。
2.2 Base64处理的三种模式
验签时接触到的字符串,不管是公钥还是签名,基本都是Base64编码。但Base64也有讲究。标准Base64.getDecoder()对换行符非常严格,一旦字符串里出现\r\n,它会直接抛IllegalArgumentException。而很多配置中心、配置文件里粘贴的密钥,中间往往带着换行。
常见三种解法:
Base64.getMimeDecoder():容忍换行符,适合处理多行的公钥字符串。- 先手动去掉字符串里的
\n、\r、空白,再用标准解码。 - 如果对方返回的是URL安全的Base64(
-和_替代+和/),就要用Base64.getUrlDecoder()。
我在处理外部接口时习惯写一个统一的清洗方法:先去掉所有空白字符,再尝试标准解码,失败就换URL解码。这样能覆盖绝大多数情况。
2.3 当对方给的不是公钥而是证书
有些接口给你的是一个.cer或.crt证书文件,这时候需要用CertificateFactory来解析:
import java.io.ByteArrayInputStream; import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; public static PublicKey loadPublicKeyFromCert(byte[] certBytes) throws Exception { CertificateFactory cf = CertificateFactory.getInstance("X.509"); X509Certificate cert = (X509Certificate) cf.generateCertificate(new ByteArrayInputStream(certBytes)); return cert.getPublicKey(); }证书解析一般不会出问题,但要注意证书过期和链校验。如果只是验签,一般只取公钥即可。如果对方要求完整校验证书链,那就要写专门的逻辑了,本文不展开。
2.4 动手前先做一次“自测”
拿到公钥和样例签名后,别急着写完整流程。我建议先写一个几十行的临时类,把公钥加载、签名解码、算法验证三件事跑通。把样例数据的原文、签名、公钥都作为常量放进去,替换成你手头的真实数据,看看能否验签通过。这一步能过滤掉八成的问题,而且能帮你快速判断算法是v1.5还是PSS。
3. 核心实现:SHA256withRSA/PSS验签的标准姿势
到了这一步,假设你已经有了一份能用的公钥,并且对方的文档确认了要用PSS。接下来就是Java代码的实现细节。PSS验签并不难,难的是参数必须和签名方完全一致。
3.1 最基本的RSASSA-PSS验签代码
下面这段代码是我在实际项目中验证过的版本,兼容Java 8及以上的标准库,不需要额外依赖。
import java.security.*; import java.security.spec.MGF1ParameterSpec; import java.security.spec.PSSParameterSpec; import java.util.Base64; public class PssVerifyDemo { public static boolean verifyPss( String data, String signatureBase64, PublicKey publicKey) throws Exception { // 1. 初始化算法,注意名称必须是 RSASSA-PSS Signature signature = Signature.getInstance("RSASSA-PSS"); // 2. 配置PSS参数:摘要算法、MGF1摘要算法、盐长度,后面会详细讲 PSSParameterSpec spec = new PSSParameterSpec( "SHA-256", // 摘要算法 "MGF1", // MGF1 MGF1ParameterSpec.SHA256, // MGF1内部使用的摘要算法 32, // 盐长度,单位字节 1); // Trailer field,固定为1 signature.setParameter(spec); // 3. 初始化验签 signature.initVerify(publicKey); signature.update(data.getBytes(StandardCharsets.UTF_8)); // 4. 解码签名并验证 byte[] sigBytes = Base64.getDecoder().decode(signatureBase64); return signature.verify(sigBytes); } }这段代码运行的前提是:签名方也用SHA-256做摘要、盐长32字节、MGF1用SHA-256。这些参数不是写死就行的,下文会说清楚每个参数怎么定。
3.2 参数逐个拆解:盐长、MGF1、TrailerField
PSS参数中,最容易出问题的就是盐长度(salt length)。
- 如果签名方用的盐长度是32字节,你验签时配成48或64,就会直接抛
SignatureException,提示PSS salt length mismatch。 - Java 8早期版本对盐长处理比较死板,但没有默认值一说,必须显式指定和签名方一致的长度。
- 不同语言的实现默认盐长可能不同。比如Java的
RSASSA-PSS配合PSSParameterSpec.DEFAULT用的是20字节;而OpenSSL里RSA_PKCS1_PSS_PADDING默认是最大盐长,也就是emLen - hLen - 2。如果对方是C++或Node.js写的,你们的盐长极易对不上。
MGF1参数方面,绝大多数场景都是MGF1配SHA-256,和你用的摘要算法保持一致。但也有极少数老系统会用MGF1配SHA-1,这样就算是“SHA256withRSA/PSS”,实际盐和MGF1用的摘要也可能不一致。这个只能靠对方文档或者抓包测试去确认。
Trailer field在标准PSS中永远是1,记住这点就够。
3.3 不同JDK版本对PSS的支持情况
这部分很关键。Java 8里RSASSA-PSS是存在的,但早期的update版本对某些密钥长度或参数组合的校验并不完整。在实际项目中我遇到过:同样的代码,在Java 8某个小版本上验签失败,升级到Java 11之后直接通过,没有任何代码改动。
建议:
- Java 8至少更新到192版本以上,越新越好。
- Java 11和17对PSS的支持已经非常稳定,新项目直接用这两个版本。
- 如果有老旧的JDK 6或7环境,标准库根本不含
RSASSA-PSS,那就只能换JDK或引入Bouncy Castle。
Bouncy Castle的引入方式是增加依赖,然后在代码里设置Provider优先级:
Security.addProvider(new BouncyCastleProvider()); Signature signature = Signature.getInstance("SHA256withRSAandMGF1", "BC");这种情况下,参数配置方式和标准库略有不同,需要额外注意。
3.4 通过“正反测试”确认自己的验签逻辑
写完验签代码后,先别急着对接。我建议你用同一把公钥做三组测试:
- 用正确的数据和签名,预期结果应为true。
- 修改任意一个数据字节,预期结果应为false。
- 换一组完全无关的假签名,预期结果应为false,且不抛异常。
这段验证逻辑能帮你发现算法配置错误、Base64处理失误、以及公钥加载的问题。很多联调中出现的“验签通过率低”现象,其实都是因为签名方在特殊情况下产出了某些边缘格式的签名,而你的验签代码对这些格式不够宽容。
4. 实战中常见的坑:从报错到解决的排查记录
这部分是干货中的干货。我把这几年在各项目中遇到的PSS验签问题整理成了一张速查表,每条后面附带详细说明和解决思路。
4.1 问题速查总表
| 现象 | 直接原因 | 解决方案 |
|---|---|---|
NoSuchAlgorithmException | 算法名称拼写错误或JDK不支持 | 使用官方名称RSASSA-PSS,确认JDK版本 |
InvalidKeyException | 公钥格式错或算法不匹配 | 确认是X.509格式,检查密钥算法是否为RSA |
SignatureException: PSS salt length mismatch | 盐长参数不一致 | 与签名方确认盐长,修改PSSParameterSpec |
SignatureException: java.security.SignatureException: Signature length not correct | 签名数据被截断或Base64解码异常 | 比对原始签名长度,检查Base64字符串是否完整 |
IllegalArgumentException: Illegal base64 character | Base64字符串含换行或非法字符 | 清洗字符串,使用MIME解码器 |
| 验签结果返回false,且无任何异常 | 参数不一致,或摘要算法不正确 | 用两套算法(v1.5和PSS)分别验证,定位问题 |
| 同一份代码,某条环境能过,某条环境不能过 | JDK或Provider版本差异 | 统一JDK版本,优先使用标准库 |
4.2 场景一:对方文档写“SHA256withRSA”,实际上是PSS
这种是最磨人的。某次对接一个政务接口,对方工作人员坚持说他们就是“SHA256withRSA”,我按照传统方式验签,怎么都是false。调试无果后,我拿到一组样例数据,写代码同时用SHA256withRSA和RSASSA-PSS去验,结果PSS分支返回了true。后来仔细看对方技术文档,才发现在附注里写了一句“采用RSA-PSS填充算法”。
我的经验是:当你发现常规验签过不了时,别再抠自己的代码,直接双算法测试。这个方法能帮你迅速判断方向,省下半天排查时间。
4.3 场景二:盐长不一致导致的诡异false
还有一次是自家两个系统联调,签名方用Bouncy Castle,验证方用标准JDK。两边都觉得自己没错,但验签就是失败。最后打印出两边配置的PSSParameterSpec一对比,发现签名方用的盐长是64,验证方用的是32。
这种问题最气人,因为没有报错,只有false。排查技巧其实很粗暴:写个小程序,把签名方和验证方的PSS参数都打出来,逐项对比。包括摘要、MGF1算法、盐长、Trailer Field,四项全对齐才行。
4.4 场景三:PKCS#1公钥格式不识别
公钥如果是MIIBCgKCAQEA...开头,Java直接加载会报InvalidKeySpecException。当时我这边着急,对方又没法换格式,最后是写了一个小工具类,把PKCS#1转成X.509。其实原理不复杂:PKCS#1公钥的ASN.1结构里,前面补一段固定的SPKI前缀,填上RSA OID和Bit String标记,就能转成标准格式。
不过如果你不想维护这种转换代码,也可以直接用Bouncy Castle的JcaPEMKeyConverter,几行代码就搞定。
4.5 场景四:URL安全的Base64
外部接口为了在URL参数里传递签名,经常用URL安全的Base64编码。这种编码里,+被替换成-,/被替换成_,而且通常不会自动补=号。如果你用标准解码,偶尔能解出来但不正确,或者直接抛异常。解决办法就是统一用Base64.getUrlDecoder()去解签名内容。
4.6 场景五:多行公钥字符串处理不当
很多对方的示例代码里,公钥是一个长字符串,但复制到配置中心后,会自动被格式化,中间插入换行。标准解码会直接炸。这里推荐使用Base64.getMimeDecoder()去加载,它能自动忽略换行符。
不过要注意,如果对方给的字符串里既有换行又有+//,那就得先清洗,再逐段处理。
4.7 跨平台调用的隐形差异
PSS本质上是对字节做签名,不同语言在签名前对原文编码可能有差异。最常见的是:Java源码里的字符串做了getBytes(),没指定字符集,导致在Windows上用了GBK编码,Linux上用了UTF-8,结果两边算出的摘要不一样,验签当然失败。
无论在任何语言里,都要显式指定字符集为UTF-8,通常用getBytes(StandardCharsets.UTF_8)。如果对方平台不是Java,还要注意对方拿到的原文到底是原始JSON还是格式化后字符串。很多时候,缩进、空格、字段顺序不同,都会导致摘要变化。
4.8 安全性和性能建议
验签场景虽然不如签名那么敏感,但也有几条原则要遵守:
- 公钥对象尽量复用,不要每次验签都重新加载和解析,尤其是证书解析在并发下开销不小。
- 验签失败要记日志,但不要记录完整签名原文,防止敏感信息泄露。
- 推荐使用带随机盐的PSS,而不是退化为PKCS#1 v1.5。即使联调费点劲,也别牺牲安全性。
- 如果公钥来自对方服务器,接入时最好通过指纹或证书固定公钥,防止中间人替换。
5. 总结一点个人感触
踩过那么多坑以后,我现在接到验签需求,第一件事一定不是写代码,而是先去核对四样东西:算法全名、填充方式、盐长度、编码格式。把这几项和对方挨个确认清楚,后面的工作基本都是顺水推舟。
另外,真的别觉得PSS麻烦就退回v1.5。技术债务总有一天要还,安全上的妥协尤其不值得。PSS的参数看起来多,但一旦写成一套标准模板,以后复用起来非常快。我现在团队里的签名工具类,就封装了PSS配置加载、密钥格式自动识别、Base64清洗这几个能力,新接口接入的排障时间从几小时降到了十几分钟。
最后再分享一个终端小技巧:拿到对方公钥、签名样例后,用OpenSSL命令行也能快速试一次,语法大致是openssl dgst -sha256 -verify public.pem -signature sign.bin data.txt,如果命令行能过,说明公钥和签名没问题,问题只在你的Java参数配置。这样就能彻底把锅定位到自己代码这边,接下来照着文章里的参数对齐思路去调,很快就能跑通。