RSA签名与验签完全指南:密钥格式、填充模式与跨语言实操
做接口对接的时候签名验签永远是绕不开的一环。尤其RSA签名前后端联调、服务间通信、开放平台授权几乎都靠它来确认“消息是谁发的”和“消息有没有被人改过”。但说实话很多人把RSA签名和RSA加密混为一谈拿着公钥当私钥用或者选错了填充方式导致验签死活不过这些坑我都踩过。今天这篇就把RSA签名和验签从原理到实操完整讲透包括密钥格式、填充模式、跨语言互操作这些最容易出问题的地方给你一份可以直接抄作业的参考。RSA签名验签本质上解决三件事完整性、身份认证和不可抵赖。完整性是确认数据没有被篡改身份认证是确认消息确实来自持有私钥的一方不可抵赖是指私钥持有者无法否认自己签过这个数据。背后的数学原理是公私钥配对私钥签名公钥验签私钥只有你自己有公钥可以发给任何人。这个方向是单向的用私钥签出来的签名只有配对的公钥能验证通过其他人即使拿到签名也伪造不出相同的结果。适合看这篇内容的人主要是后端开发、接口联调工程师、开放平台对接的开发以及所有需要在自己的系统里落地签名机制的从业者。不管你是用Java、Python、Node还是Go底层的RSA逻辑都是同一套代码只是载体。1. 整体设计签名不是加密先把这个概念掰正1.1 签名与加密的本质区别很多人最开始搞混的一件事就是RSA加密和RSA签名到底差在哪。加密的目的和签名的目的完全相反。简单说RSA加密是用接收方的公钥加密只有接收方的私钥能解密。核心目的是“保密”。而RSA签名是用发送方的私钥对数据做签名任何持有发送方公钥的人都能验证。核心目的是“防篡改和防抵赖”。一个保护数据不被别人看到一个保护数据不被别人冒充。我用一个生活化的类比。加密就是你把一封信锁进保险箱锁是收件人的锁只有收件人的钥匙能打开路上任何一个人捡到箱子都看不内容。签名就是你在一封信上按下指纹所有人都可以对照你的备案指纹确认这封信确实是你按的但任何人都伪造不了你的指纹。所以对接第三方平台的时候如果你要往对方系统发请求就用你自己平台的私钥签名对方拿你平台的公钥验签。反过来第三方平台回调你的接口就用第三方平台的私钥签名你用它的公钥验签。这个方向如果搞反了签名验证必然失败。1.2 为什么不直接对原始数据签名实际业务里面很少会对一整段很长的请求体直接做RSA运算。RSA签名本身对数据长度有限制PKCS#1 v1.5方式下RSA 2048位密钥最多只能签245字节的数据如果直接签完整请求体数据一长就签不动了。所以标准做法是先对原始数据做摘要用一个哈希算法把任意长度的数据“压缩”成一个固定长度的摘要值比如用SHA-256得到32字节的摘要。然后再对这个摘要做RSA私钥运算。这样做的好处有两个一是摆脱了RSA对数据长度的限制二是哈希算法对数据极其敏感原文哪怕只改一个字节摘要值就完全变了篡改行为会立刻暴露。整个签名的数据流是原始报文 - SHA-256摘要 - 私钥加密签名- 生成签名值。验签的数据流是原始报文 - SHA-256摘要 - 公钥解密验签- 对比摘要。这里有一个关键认知私钥做签名本质上是对摘要值做了一次加密运算公钥验签本质上是对签名值做了一次解密运算。很多老资料里说“私钥加密、公钥解密”说的其实就是签名验签过程。但严谨起见我们在工程上仍然区分签名和加密因为RSA加密另有语义不能混用。2. 实操流程从密钥生成到签名验签完整落地2.1 生成RSA密钥对实操的第一步是生成密钥对。最常用的工具是OpenSSL。以RSA 2048位为例命令行如下# 生成私钥PKCS#1格式 openssl genrsa -out private.pem 2048 # 从私钥中提取公钥 openssl rsa -in private.pem -pubout -out public.pem生成的private.pem内容长这样这是PEM编码的PKCS#1私钥-----BEGIN RSA PRIVATE KEY----- MIIEowIBAAKCAQEA... -----END RSA PRIVATE KEY-----公钥文件长这样-----BEGIN PUBLIC KEY----- MIIBIjANBgkqh... -----END PUBLIC KEY-----私钥生成之后一定要妥善保管私钥一旦泄露等于你的签名身份被人冒充后果非常严重。建议私钥权限设为600只允许当前用户读写。私钥不要提交到代码仓库更不要通过聊天工具明文传输要用专门的密钥管理系统托管。关于密钥长度现在最低建议是2048位安全要求高的场景用3072或4096。1024位已经被认为不安全不要再用了。密钥长度会影响签名性能2048位签名一次大概需要几毫秒对绝大多数业务完全够用。2.2 一步到位的密钥格式转换实际开发中遇到的密钥格式问题是跨语言联调的第一大坑。Java那边生成的密钥拿到Node或者PHP里面验签经常报“invalid key format”。原因就是不同语言默认使用的密钥编码格式不一样。RSA私钥常见的编码格式有两种PKCS#1和PKCS#8。公钥常见的有PKCS#1 RSAPublicKey和X.509 SubjectPublicKeyInfo。用OpenSSL一行命令就能转换# PKCS#1私钥转PKCS#8 openssl pkcs8 -topk8 -inform PEM -in private.pem -outform PEM -nocrypt -out private_pkcs8.pem # PKCS#8私钥转PKCS#1 openssl rsa -in private_pkcs8.pem -out private_pkcs1.pem # 查看密钥详细信息 openssl rsa -in private.pem -text -noout如果是Java语言读入PKCS#8格式私钥最省事。Java原生的KeyFactory只支持PKCS#8解析。而Node的crypto模块两种格式都能解析。Python的cryptography库两者都支持但是需要显式指定格式。所以我的建议是为兼容性考虑统一转成PKCS#8格式。私钥统一PKCS#8公钥统一X.509格式这样跨语言对接时能减少一大半问题。2.3 签名代码示例Python版本用Python做签名验签推荐使用cryptography库比直接用pycryptodome更简洁。from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import padding from cryptography.hazmat.primitives import serialization # 加载私钥 PKCS#8 PEM格式 with open(private_pkcs8.pem, rb) as key_file: private_key serialization.load_pem_private_key( key_file.read(), passwordNone ) # 加载公钥 with open(public.pem, rb) as key_file: public_key serialization.load_pem_public_key( key_file.read() ) # 原始待签名数据 message bmerchant_id1001order_no20250101001amount99.50currencyCNY # 签名使用SHA-256摘要 PSS填充 signature private_key.sign( message, padding.PSS( mgfpadding.MGF1(hashes.SHA256()), salt_lengthpadding.PSS.MAX_LENGTH ), hashes.SHA256() ) import base64 signature_b64 base64.b64encode(signature).decode(utf-8) print(base64签名值:, signature_b64) # 验签 try: public_key.verify( base64.b64decode(signature_b64), message, padding.PSS( mgfpadding.MGF1(hashes.SHA256()), salt_lengthpadding.PSS.MAX_LENGTH ), hashes.SHA256() ) print(验签通过) except Exception as e: print(验签失败:, e)这部分代码里最值得注意的地方是PSS填充和salt_length参数。PSS的salt_length有MAX_LENGTH和DIGEST_LENGTH两种常见取值签名和验签时必须保持一致否则验签必失败。我在实际对接中遇到过对方用DIGEST_LENGTH签名、我用MAX_LENGTH验签的情况卡了半天才排查出来。2.4 Java、Node和Go的实现要点不同语言实现RSA签名API不同但底层逻辑完全一致。我把三种主流语言的签名片段都列出来方便对照。Java标准库实现// 签名 Signature signature Signature.getInstance(SHA256withRSA); signature.initSign(privateKey); signature.update(message.getBytes(UTF-8)); byte[] sigBytes signature.sign(); String sigBase64 Base64.getEncoder().encodeToString(sigBytes); // 验签 Signature verifier Signature.getInstance(SHA256withRSA); verifier.initVerify(publicKey); verifier.update(message.getBytes(UTF-8)); boolean isValid verifier.verify(Base64.getDecoder().decode(sigBase64));注意Java里的SHA256withRSA实际上使用的是PKCS#1 v1.5填充不是PSS。如果跨语言联调时对方使用的是PSS填充Java这边要改用RSASignature配合PSS参数写法复杂一些。这也是最常见的跨语言验签踩坑点。Node.js实现const crypto require(crypto); const fs require(fs); // 签名 const privateKey fs.readFileSync(private_pkcs8.pem, utf8); const sign crypto.createSign(RSA-SHA256); sign.update(message); sign.end(); const signature sign.sign(privateKey, base64); // 验签 const publicKey fs.readFileSync(public.pem, utf8); const verify crypto.createVerify(RSA-SHA256); verify.update(message); verify.end(); const isValid verify.verify(publicKey, signature, base64);Node的crypto模块用起来很直观RSA-SHA256默认走的是PKCS#1 v1.5填充。如果需要PSS需要传入额外的选项对象。Go实现import ( crypto crypto/rand crypto/rsa crypto/sha256 encoding/base64 ) // 签名 hashed : sha256.Sum256(message) signature, err : rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, hashed[:]) sigBase64 : base64.StdEncoding.EncodeToString(signature) // 验签 sigBytes, _ : base64.StdEncoding.DecodeString(sigBase64) err rsa.VerifyPKCS1v15(publicKey, crypto.SHA256, hashed[:], sigBytes)3. 核心细节填充模式、摘要算法和编码格式深度解析3.1 PKCS#1 v1.5与PSS填充怎么选RSA签名有两种填充模式这是整个验签过程中最容易出问题的地方。一种是PKCS#1 v1.5另一种是PSS。PKCS#1 v1.5是历史最悠久、兼容性最好的填充方式。它的填充结构是固定的前面是固定字节的填充块后面紧跟摘要算法标识和摘要值。几乎所有语言的标准库都支持跨语言联调时大概率用的是它。缺点是安全性上存在一定理论风险有一些针对实现的攻击方式但在大多数业务场景下依然可用。PSS是更安全的填充方式它引入了随机盐值即使同一份数据每次签名出来的结果都不一样这杜绝了某些确定性攻击。OpenSSL从1.1.1开始默认签名算法变成了RSA-PSS如果密钥支持。但是PSS的跨语言兼容性不如v1.5因为不同语言对PSS的参数支持程度不一样尤其体现在salt_length的处理上。我的建议是如果涉及跨语言系统联调提前约定好填充模式。双方技术栈都支持PSS就用PSS安全性更高。如果有一方是老系统优先统一使用PKCS#1 v1.5避免参数不一致导致的验签失败。3.2 摘要算法的选择摘要算法决定了签名值的长度和安全性。常见的有SHA-1、SHA-256、SHA-384、SHA-512、SM3等。SHA-1已经不安全不要再用于签名部分OpenSSL版本默认禁止SHA-1签名。用了会报警告甚至直接报错。现在主流选择是SHA-256除非有合规要求用SM2/SM3那要走国密算法体系不是普通RSA了。记住一个适配关系RSA 2048 SHA-256是当前最常见、最稳妥的组合。密钥越长摘要算法也可以相应升级但SHA-256对绝大多数业务已经足够。不同的摘要算法配合不同的填充方式会产生不同的算法标识字符串。比如Java里的SHA256withRSA含义就是SHA-256摘要RSA的PKCS#1 v1.5填充签名。如果你看到SHA256withRSA/PSS那是SHA-256PSS填充。命名里这些细节藏得非常深对接时一定要逐字对齐。3.3 签名值的编码与传输RSA 2048签名出来的是一个256字节的二进制数据。这个二进制数据不能直接拼在HTTP参数里传因为里面有大量不可见字符、换行符、特殊字符会被URL编码规则破坏掉。所以签名值通常以Base64编码成可打印的ASCII字符串后传输。这里又有一个经典的坑Base64有三种常见的变体——标准Base64、URL Safe Base64、不带填充的标准Base64。Java的Base64.getEncoder()输出标准Base64会包含和/字符。Node的Buffer.toString(base64)也是标准Base64。但有些框架默认用URL Safe Base64会把替换成-把/替换成_。如果你收到的签名值里有-和_字符那大概率是URL Safe Base64需要转换回标准Base64再解码。反之如果你把标准Base64带和/的签名直接拼在URL里传对方解析时可能会把当成空格处理导致验签失败。经验做法是传输签名值前统一用URL编码处理或者干脆约定使用URL Safe Base64。另一个细节是字符集问题。做签名用的原始报文在计算摘要之前需要转成字节数组这时候编码方式必须统一。绝大多数场景用UTF-8。如果一方用GBK一方用UTF-8同样内容的字节序列不一样摘要值就不同验签必然失败。这类问题极难排查因为从肉眼看到的数据看两边报文一模一样。3.4 待签名内容的覆盖范围这个点虽然放在后面说但实际对接中却是最容易产生争议的地方。待签名数据到底包含哪些字段参数顺序怎么排要不要拼接时间戳和随机数这些规则如果两边不一致验签一定失败。常见的做法是把请求参数按照字典序排序然后拼接成key1value1key2value2这样的字符串再做签名。有些平台还会要求把某个固定字段放在最前面。有些平台则要求对整个原始JSON报文直接做签名不做任何排序。我处理过的一个案例是某开放平台的回调接口他们要求签名字符串只包含业务参数不包括签名本身和sign_type字段。我一开始把sign_type也算进去了结果验签一直失败。后来仔细看他们的文档才发现签名原文里明确排除了几个签名相关的参数。这种规则完全依赖对接文档没有统一标准唯一靠谱的方法是仔细阅读对方文档的签名示例拿示例数据手工算一遍对齐了再写代码。4. 常见问题与排查技巧实录4.1 排查验签失败的通用步骤验签失败的排查是有标准套路的。我的经验是把整个链路从头到尾捋一遍按顺序检查六项第一看密钥。验签用的公钥是不是对方私钥对应的那个公钥有没有公钥和私钥不是一对的情况这种问题肉眼根本看不出来可以用OpenSSL命令对比密钥模数。公钥和私钥的模数如果一致才说明是一对。# 分别查看公钥和私钥的模数对比是否一致 openssl rsa -pubin -in public.pem -text -noout | grep modulus openssl rsa -in private.pem -text -noout | grep modulus两边的modulus值一样密钥对才是匹配的。这是排查密钥问题最直接的手段。第二看签名原文。两边待签名的内容是否完全一致字节级别一致字符编码是否都是UTF-8特殊字符有没有被转义空格有没有多出来建议把双方拼接的签名字符串打印出来做一次逐字符对比。第三看摘要算法。都是SHA-256吗一方是不是用了SHA-1另一方用的SHA-256算法不匹配签名验签必挂。第四看填充模式。一方PSS另一方PKCS#1 v1.5或者PSS的salt_length不一致这个只能通过代码和文档确认。第五看签名值编码。Base64标准变体还是URL Safe签名值传过来之后有没有被截断有没有把换行符带进签名里Base64字符串首尾有没有多余的空格第六看签名生成时间。有些平台会在签名里带上时间戳并要求时效校验如果系统时间不同步或者时区不对也会导致验签被拒绝。这种情况不是签名验证本身失败而是在验证通过之后的业务校验阶段被拦下返回的错误码可能误导人。4.2 常见错误速查表问题现象可能原因解决方案验签一直失败更换密钥后仍然失败待签名原文不一致比如字段顺序、URL编码方式不同打印双方签名字符串逐字符对比重点看空格、大小写、转义Java验签通过Node验签失败填充模式不同Java默认v1.5Node接收方用了PSS统一填充模式或用相同库/工具交叉验证Python PSS签名的值每次不同PSS带随机盐属正常现象验签时保证salt_length参数一致即可密钥解析报“invalid format”密钥格式不匹配PKCS#1和PKCS#8混用统一转成PKCS#8私钥和X.509公钥签名值中有-和_使用了URL Safe Base64转回标准Base64再解码或统一约定传输格式中文参数验签失败两边字符编码不一致统一使用UTF-8编码做字节序列老系统验签报算法被禁用SHA-1签名被OpenSSL安全策略禁用升级到SHA-256并同步更新对端4.3 一个真实场景的排查实录有一次对接内部的一个结算系统对方提供了Java写的SDK我这边用Python对接。按照对方的文档写好签名逻辑后验签怎么都不通过。一开始怀疑是密钥对不匹配对比了模数完全一致。然后怀疑是签名原文拼接方式有问题打印出两边拼接的签名字符串用肉眼对比了好几遍也没发现差异。最后把两边拼接字符串的字节序列用十六进制打印出来才看到问题对方SDK拼接时对空值字段做了特殊处理把空字符串字段直接丢弃了而我这边是把空字符串保留为field,多了一个号。这个问题的隐蔽性在于肉眼看到的字符串几乎一样只有到了字节级别才能发现差异。这一段经历给我的教训是凡是遇到签名相关的问题不要相信肉眼直接把字节序列拉出来对比往往能快速定位。4.4 一些工程上的额外建议密钥管理方面。如果系统里有多个合作方建议每个合作方使用独立的密钥对不要一把私钥签名所有平台的报文。一旦某个平台侧泄露公钥或者被入侵独立密钥可以把损失隔离到单个业务线。私钥需要轮换的时候也要注意新旧密钥的过渡期建议提前生成新密钥对让合作方先配好新公钥再切换签名私钥。签名日志方面。生产环境一定要记录签名相关的关键信息包括待签名原文、签名值、验签结果、请求时间。但要注意签名原文里头可能包含手机号、身份证号这类敏感信息落日志前做好脱敏。日志是排查线上验签问题最重要的依据没有日志出了问题只能靠猜。并发性能方面。RSA签名属于CPU密集型操作2048位签名一次大概消耗几毫秒。如果系统峰值时每秒需要处理上千次签名单线程肯定会成为瓶颈。建议对私钥签名操作做并发压测必要时用线程池或异步队列削峰。公钥验签的性能开销稍低但同样需要关注。单元测试方面。每个合作方的签名规则不同建议针对不同规则各写一组测试用例。测试数据直接用对方文档里的示例确保测试用例在联调之前就把平台自身的签名逻辑验证到位。5. 最后再分享两个实操心得第一个心得是永远不要相信对接文档里的“示例代码”可以原样复制到生产环境。很多平台的示例代码是单机演示用的没有考虑异常处理、日志记录、密钥定期轮换这些问题。我见过有人把示例代码里的私钥直接写死在配置里发布了半年后来泄露了才紧急处理。正确的做法是用示例代码理解逻辑然后按自己团队的技术规范重新实现一版。第二个心得是签名和验签最后一定要留一条“回退”路径。第三方平台的签名算法升级是很常见的事情比如从SHA-1升级到SHA-256或者从PKCS#1 v1.5升级到PSS。升级过程中必然存在新旧算法并存的一段时期。我的经验是在验签逻辑里同时保留旧算法和新算法根据配置动态切换等确认新算法稳定运行一段时间后再关闭旧算法。这样做虽然代码上多了一点复杂度但能避免在对方切换算法时业务中断。RSA签名验签本身并不复杂但涉及密钥格式、填充模式、编码、摘要算法、签名原文约定这五层因素任意一层不一致都会导致失败。经验丰富的开发者会把这些规则全部固化到代码和自动化测试里而不是每次联调都靠现场排查。希望这篇内容能帮你把这些坑一次跳过把签名验签做成你系统里最稳定的那一个环节。