
在做接口对接时最让人头疼的事之一就是签名验签失败。明明算法写对了、公钥也加载了、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填充。这是老牌方案兼容性极好几乎所有语言和框架都支持。但它在数学上存在一些已知的弱点比如在特定条件下攻击者可以构造出某个消息使得验签通过这在安全性要求高的场景里是不可接受的。PSSProbabilistic Signature Scheme是更现代的替代方案。它的核心特点是签名过程中会生成随机盐salt同一个消息每次签名产生的密文都不一样但验签时都能正确通过。这种概率性让签名更难被伪造。所以在金融、政务、运营商这类对安全要求极高的行业里越来越多的接口要求使用RSASSA-PSS。如果你做的是新项目对接对方要求PSS其实是很正常的趋势。1.3 Java中算法名称的对照关系一个很常见的坑是很多业务文档里写着“SHA256withRSA/PSS”但Java标准库里的算法名称并不是这样。你需要用特定的名称去注册算法否则会直接抛NoSuchAlgorithmException。场景JDK 8 对应算法名称说明传统SHA256withRSASHA256withRSA使用PKCS#1 v1.5填充Java原生PSSRSASSA-PSS标准PSS需额外配置参数别名部分JDKSHA256withRSA/PSS部分JDK的独立算法名不通用第三方库BouncyCastleSHA256withRSAandMGF1BC风格配置方式不同如果你使用的是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、TrailerFieldPSS参数中最容易出问题的就是盐长度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格式检查密钥算法是否为RSASignatureException: PSS salt length mismatch盐长参数不一致与签名方确认盐长修改PSSParameterSpecSignatureException: java.security.SignatureException: Signature length not correct签名数据被截断或Base64解码异常比对原始签名长度检查Base64字符串是否完整IllegalArgumentException: Illegal base64 characterBase64字符串含换行或非法字符清洗字符串使用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参数配置。这样就能彻底把锅定位到自己代码这边接下来照着文章里的参数对齐思路去调很快就能跑通。