支付为什么要验两次签?------从渠道验签到服务间信任边界与 RSA2
本文以常见的 RSA-SHA256(RSA2)签名方式为例。 在接入第三方支付的时候,我第一次注意到一个问题:
如果微信支付或者支付宝已经对支付通知进行过签名验证,为什么支付服务收到通知后,还需要继续对发送给下游业务服务的消息进行签名?
一开始我的理解比较简单:
第三方支付已经证明这个请求来自可信的支付渠道,那么支付服务收到请求之后,为什么不能直接把结果交给业务服务?
但把支付回调链路拆开以后会发现,这里面实际上存在的是两段不同的信任关系。
可以抽象成:
第一段解决的是:
**这个支付通知是否能够在持有并信任对应公钥/证书的前提下,通过验签验证签名与消息内容是否匹配,从而建立对消息来源和完整性的信任。
第二段解决的是:
业务服务收到的支付结果,是否确实来自自己信任的支付服务,并且消息在服务之间传递的过程中没有被篡改?
所以,虽然两段流程中都出现了"签名"和"验签",但它们建立的信任边界并不相同。
这也是我后来理解支付回调代码时比较重要的一个转变:
验签并不是一个孤立的技术动作,而是发生在具体信任边界上的安全校验。
1. 先把支付回调链路拆开
假设用户完成了一笔订单支付。
支付渠道在确认支付结果后,会向我们的支付服务发送异步通知:
表面上看,这只是一个"支付成功之后通知业务系统"的流程。
但如果把它拆成两个部分,就会发现两个问题。
1.1 渠道 → 支付服务
支付服务收到一个请求:
json
{
"orderId": "ORDER001",
"amount": "100.00",
"status": "SUCCESS"
}
首先需要确认:
这个通知是否能够通过微信支付或者支付宝对应的签名验证?
同时还需要确认:
参与验证的数据是否保持完整,没有在传输过程中被修改?
因此,这里使用的是第三方支付渠道提供的验签机制。
这一层建立的是:
markdown
第三方支付
↓
支付服务
之间的信任关系。
1.2 支付服务 → 业务服务
假设支付服务已经完成渠道验签,现在需要通知订单服务:
订单 ORDER001 支付成功
这时候问题已经发生了变化。
订单服务并不需要再次判断:
"这个请求是不是微信或者支付宝发送的?"
因为这个请求本身就是由支付服务发出的。
订单服务真正需要确认的是:
这个支付结果是不是由我信任的支付服务产生的?
因此,支付服务可以使用自己的私钥对业务通知进行签名:
这里建立的是另一段信任关系:
markdown
支付服务
↓
业务服务
所以:
渠道验签通过,并不意味着后面的所有服务都可以无条件信任这个请求。
2. 为什么不能直接把渠道验签结果传下去?
这里可以构造一个简单的场景。
支付服务收到:
json
{
"orderId": "ORDER001",
"amount": "100.00",
"status": "SUCCESS"
}
完成渠道验签之后,直接调用订单服务:
bash
POST /order/payment/callback
请求内容:
json
{
"orderId": "ORDER001",
"amount": "100.00",
"status": "SUCCESS"
}
如果订单服务只根据:
"这是一个来自 HTTP 的请求"
就直接更新订单状态,那么它实际上缺少一个明确的服务身份与消息完整性验证机制。
对于订单服务来说:
支付服务 → 订单服务
和:
其他调用方 → 订单服务
都只是一次 HTTP 请求。
因此,服务之间需要建立自己的信任机制。
签名就是一种常见的实现方式。
支付服务使用自己持有的私钥:
业务服务则使用对应的公钥进行验证:
这样业务服务验证的就不再是:
"请求自己说它来自支付服务。"
而是:
"这个签名是否能够通过我信任的公钥验证?"
3. RSA2 到底签了什么?
这里有一个容易产生的误解:
RSA2 是不是把整个 HTTP 请求直接丢进去加密?
实际业务中,通常需要先按照双方约定,把参与签名的参数整理成一个确定的待签名字符串,然后对这个字符串进行签名。
例如:
ini
orderId=ORDER001&amount=100.00&status=SUCCESS
这个字符串就是这里讨论的待签名内容。
整体过程可以简单理解为:
接收方拿到业务参数和 Signature 后,需要按照完全相同的规则重新构造待签名字符串,然后进行验签。
也就是说:
css
发送方:
业务参数
↓
构造字符串 A
↓
SHA-256 + RSA 私钥
↓
Signature
接收方:
css
业务参数
↓
构造字符串 B
↓
SHA-256 + RSA 公钥验签
↓
验证 Signature
这里最关键的条件其实是:
css
字符串 A == 字符串 B
如果两边参与签名的数据不一致,即使双方使用的是正确的密钥,验签也可能失败。
4. 为什么一点字符串差异都会导致验签失败?
假设发送方签名的内容是:
ini
orderId=ORDER001&amount=100.00&status=SUCCESS
而接收方构造出来的是:
ini
orderId=ORDER001&amount=100&status=SUCCESS
肉眼看起来似乎只是:
100.00
变成了:
100
但对于签名计算来说,这已经是两个不同的输入。
同样,如果双方分别得到:
ini
orderId=ORDER001&amount=100.00&status=SUCCESS
和:
ini
amount=100.00&orderId=ORDER001&status=SUCCESS
它们同样不是同一个字符串。
因此,当验签失败时,不能第一时间认为:
RSA 算法出问题了。
更值得先检查的是:
发送方到底签了什么?接收方到底验了什么?
这也是支付签名问题中一个比较典型的排查思路。
5. 用一个最小例子验证验签失败
实际实习环境中的支付链路依赖第三方支付环境、项目配置以及对应 SDK,目前没有条件稳定复现一条完整的错误支付通知。
因此,这里不直接伪造一次"真实支付回调失败"的结论,而是用一个最小的 RSA 签名例子验证一个更基础的问题:
当签名前后的原始字符串发生变化时,验签是否还能通过?
发送方:
arduino
String content =
"orderId=ORDER001&amount=100.00&status=SUCCESS";
String sign = sign(content, privateKey);
接收方故意修改金额:
arduino
String receivedContent =
"orderId=ORDER001&amount=100&status=SUCCESS";
boolean result = verify(
receivedContent,
sign,
publicKey
);
运行伪代码:
java
// 伪代码,仅展示调用关系
verifyChannelNotification(request);
PaymentResult result = parseNotification(request);
String signContent = buildSignContent(result);
String sign = sign(signContent, privateKey);
notifyOrderService(result, sign);
实际项目中上述流程由现有支付 SDK 和业务代码共同完成,本文仅用简化代码展示理解后的调用关系。
按照数字签名验证的原理,如果接收方使用修改后的字符串进行验签,验证应当失败:
ini
发送方:
orderId=ORDER001&amount=100.00&status=SUCCESS
接收方:
orderId=ORDER001&amount=100&status=SUCCESS
其中:
yaml
100.00 != 100
因此,验签结果应该为失败。
这个实验并不是为了证明某一个具体支付渠道的行为,而只是验证一个更基础的事实:
数字签名验证依赖于双方对签名原文的一致理解。
6. 实际排查验签失败时应该看什么?
如果把问题放回真实的 HTTP 请求中,可以按照下面的链路进行排查:
6.1 先确认 HTTP 请求有没有正常到达
如果请求根本没有进入服务端业务代码,那么继续研究 RSA 没有意义。
实际排查时需要确认:
text
HTTP Status: xxx
Response Body: xxx
服务端日志:xxx
这些数据应该以实际实验结果为准,而不是根据经验直接推断。
6.2 确认 Signature 是否存在
例如:
json
{
"orderId": "ORDER001",
"amount": "100.00",
"status": "SUCCESS",
"sign": "xxxxxxxx"
}
如果签名字段在请求进入业务逻辑之前就已经丢失,那么后面的验签自然无法成功。
6.3 检查参与签名的参数
这一步通常比直接检查 RSA 算法更重要。
需要确认:
- 哪些字段参与签名?
- 参数如何拼接?
- 空值是否参与?
- 数据类型如何转换?
- 字符编码是否一致?
- 最终得到的待签名字符串是什么?
例如:
ini
发送方:
orderId=ORDER001&amount=100.00&status=SUCCESS
接收方:
ini
orderId=ORDER001&amount=100&status=SUCCESS
那么问题已经比较明确:
100.00
和:
100
导致最终输入发生了变化。
7. 不要看到验签失败就直接认为是"排序问题"
在实际排查签名问题的时候,一个很容易出现的思维惯性是:
验签失败,是不是参数排序不一致?
这个方向当然可以检查,但不能在没有证据的情况下直接下结论。
因为不同支付渠道、不同 SDK 以及不同业务系统的签名规则并不完全相同。
更可靠的排查方式应该是:
也就是说:
先确认实际协议,再解释现象,而不是先根据经验给问题贴标签。
这也是我阅读第三方支付代码时比较重要的一点体会。
8. 微信支付 V3:验签和解密是两个步骤
微信支付 API v3 的通知流程又有一个容易混淆的地方:
验签和解密不是同一个步骤。
可以把流程简化成:
验签解决的是:
这条通知的签名是否有效,消息来源和内容完整性是否符合预期?
而解密解决的是:
如何从加密的通知数据中恢复业务明文?
因此,阅读微信支付 V3 相关代码时,不能把:
验签
和:
解密
理解成同一个步骤。
实际开发中,具体的通知验签、平台证书以及通知数据解密方式,应当以微信支付对应版本的官方规范和 SDK 实现为准。
9. 回到最开始的问题:为什么需要"验两次"?
现在再回头看文章最开始的问题:
如果第三方支付已经验签了,为什么内部服务还需要验签?
答案其实已经比较清楚。
因为系统中存在两个不同的信任边界:
第一段关注的是:
第三方支付渠道发送的通知是否可信。
第二段关注的是:
业务服务收到的支付结果是否来自可信的支付服务。
所以,"验两次"并不是简单地重复做同一件事情。
它们分别对应了两个不同的信任边界。
10. 这次实习让我真正理解了什么?
需要说明的是,在实际实习中,我并没有从零实现 RSA2,也没有设计完整的支付系统。
实际工作更多是基于现有的第三方支付 SDK 和项目代码完成支付能力的接入、调用、联调,以及相关异常分支和测试处理。
但在阅读代码和处理支付流程的过程中,我开始进一步关注 SDK 背后的实现逻辑。
以前看到:
scss
sign()
verify()
可能只会把它们理解成:
"调用一个 API 完成签名。"
后来把整个链路拆开之后,我开始意识到真正值得关注的问题其实是:
谁信任谁?
为什么信任?
信任边界在哪里?
双方到底对什么数据进行签名?
验签失败的时候应该如何定位?
这让我对支付接入的理解从:
调用支付 SDK
变成了:
对我来说,这次经历真正值得记录下来的,并不是"我会调用某个支付 SDK"。
而是开始理解:
第三方支付已经验证过请求,并不意味着整个系统内部的所有服务都可以无条件信任这个请求。
信任关系是分层的。
而在具体排查验签问题时,真正需要关注的也不一定是复杂的密码学算法。
很多时候,更应该先问一个简单的问题:
发送方和接收方最终参与签名的数据,到底是不是完全一致?
这可能才是理解支付签名机制最重要的第一步。
最后说明:本文主要是基于实习期间接触第三方支付 SDK、阅读项目代码以及后续整理学习形成的理解,并不代表我完整实现过支付系统。
由于个人水平有限,文中如果存在理解或表述上的错误,也欢迎各位指出和讨论 🙏