支付为什么要验两次签?——从渠道验签到服务间信任边界与 RSA2

支付为什么要验两次签?------从渠道验签到服务间信任边界与 RSA2

本文以常见的 RSA-SHA256(RSA2)签名方式为例。 在接入第三方支付的时候,我第一次注意到一个问题:

如果微信支付或者支付宝已经对支付通知进行过签名验证,为什么支付服务收到通知后,还需要继续对发送给下游业务服务的消息进行签名?

一开始我的理解比较简单:

第三方支付已经证明这个请求来自可信的支付渠道,那么支付服务收到请求之后,为什么不能直接把结果交给业务服务?

但把支付回调链路拆开以后会发现,这里面实际上存在的是两段不同的信任关系。

可以抽象成:

flowchart TD A[微信 / 支付宝] -->|支付通知 + 渠道签名| B[支付服务] B -->|业务通知 + 服务签名| C[业务服务]

第一段解决的是:

**这个支付通知是否能够在持有并信任对应公钥/证书的前提下,通过验签验证签名与消息内容是否匹配,从而建立对消息来源和完整性的信任。

第二段解决的是:

业务服务收到的支付结果,是否确实来自自己信任的支付服务,并且消息在服务之间传递的过程中没有被篡改?

所以,虽然两段流程中都出现了"签名"和"验签",但它们建立的信任边界并不相同。

这也是我后来理解支付回调代码时比较重要的一个转变:

验签并不是一个孤立的技术动作,而是发生在具体信任边界上的安全校验。


1. 先把支付回调链路拆开

假设用户完成了一笔订单支付。

支付渠道在确认支付结果后,会向我们的支付服务发送异步通知:

sequenceDiagram participant U as 用户 participant C as 微信 / 支付宝 participant P as 支付服务 participant B as 业务服务 U->>C: 完成支付 C->>P: 支付通知 + 渠道签名 P->>P: 验证渠道签名 P->>P: 更新支付状态 P->>B: 支付结果 + 服务签名 B->>B: 验证服务签名 B->>B: 更新业务状态

表面上看,这只是一个"支付成功之后通知业务系统"的流程。

但如果把它拆成两个部分,就会发现两个问题。

1.1 渠道 → 支付服务

支付服务收到一个请求:

json 复制代码
{
  "orderId": "ORDER001",
  "amount": "100.00",
  "status": "SUCCESS"
}

首先需要确认:

这个通知是否能够通过微信支付或者支付宝对应的签名验证?

同时还需要确认:

参与验证的数据是否保持完整,没有在传输过程中被修改?

因此,这里使用的是第三方支付渠道提供的验签机制。

这一层建立的是:

markdown 复制代码
第三方支付
      ↓
支付服务

之间的信任关系。


1.2 支付服务 → 业务服务

假设支付服务已经完成渠道验签,现在需要通知订单服务:

复制代码
订单 ORDER001 支付成功

这时候问题已经发生了变化。

订单服务并不需要再次判断:

"这个请求是不是微信或者支付宝发送的?"

因为这个请求本身就是由支付服务发出的。

订单服务真正需要确认的是:

这个支付结果是不是由我信任的支付服务产生的?

因此,支付服务可以使用自己的私钥对业务通知进行签名:

flowchart LR A[支付服务] -->|私钥签名| B[业务通知] B --> C[业务服务] C -->|公钥验签| D{验签结果} D -->|成功| E[继续处理] D -->|失败| F[拒绝处理]

这里建立的是另一段信任关系:

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 请求。

因此,服务之间需要建立自己的信任机制。

签名就是一种常见的实现方式。

支付服务使用自己持有的私钥:

flowchart TD A[业务参数] --> B[构造待签名字符串] B --> C[SHA-256] C --> D[RSA 私钥签名] D --> E[Signature]

业务服务则使用对应的公钥进行验证:

flowchart TD A[业务参数] --> B[重新构造待签名字符串] B --> C[SHA-256] C --> D[RSA 公钥验签] E[Signature] --> D D --> F{验签结果} F -->|成功| G[继续处理] F -->|失败| H[拒绝处理]

这样业务服务验证的就不再是:

"请求自己说它来自支付服务。"

而是:

"这个签名是否能够通过我信任的公钥验证?"


3. RSA2 到底签了什么?

这里有一个容易产生的误解:

RSA2 是不是把整个 HTTP 请求直接丢进去加密?

实际业务中,通常需要先按照双方约定,把参与签名的参数整理成一个确定的待签名字符串,然后对这个字符串进行签名。

例如:

ini 复制代码
orderId=ORDER001&amount=100.00&status=SUCCESS

这个字符串就是这里讨论的待签名内容。

整体过程可以简单理解为:

flowchart TD A[业务参数] --> B[按照约定构造待签名字符串] B --> C[SHA-256] C --> D[RSA 私钥签名] D --> E[Signature]

接收方拿到业务参数和 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 请求中,可以按照下面的链路进行排查:

flowchart TD A[HTTP 请求] --> B{服务端是否收到?} B -->|否| C[先排查网络 / 请求问题] B -->|是| D{Signature 是否存在?} D -->|否| E[排查签名字段传递] D -->|是| F[检查参与签名的业务参数] F --> G[重新构造待签名字符串] G --> H[对比发送方与接收方的签名原文] H --> I[进行 RSA 验签] I --> J{验签结果} J -->|成功| K[继续业务处理] J -->|失败| L[继续定位签名数据 / 密钥 / 编码等问题]

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 以及不同业务系统的签名规则并不完全相同。

更可靠的排查方式应该是:

flowchart TD A[发现验签失败] --> B[确认实际签名规则] B --> C[查看官方文档 / SDK / 现有代码] C --> D[确认实际待签名字符串] D --> E[对比发送方与接收方] E --> F[定位具体差异]

也就是说:

先确认实际协议,再解释现象,而不是先根据经验给问题贴标签。

这也是我阅读第三方支付代码时比较重要的一点体会。


8. 微信支付 V3:验签和解密是两个步骤

微信支付 API v3 的通知流程又有一个容易混淆的地方:

验签和解密不是同一个步骤。

可以把流程简化成:

flowchart TD A[微信支付通知] --> B[接收 HTTP 请求] B --> C[验证通知签名] C -->|失败| D[拒绝处理] C -->|成功| E[解密通知数据] E --> F[得到业务数据]

验签解决的是:

这条通知的签名是否有效,消息来源和内容完整性是否符合预期?

而解密解决的是:

如何从加密的通知数据中恢复业务明文?

因此,阅读微信支付 V3 相关代码时,不能把:

复制代码
验签

和:

复制代码
解密

理解成同一个步骤。

实际开发中,具体的通知验签、平台证书以及通知数据解密方式,应当以微信支付对应版本的官方规范和 SDK 实现为准。


9. 回到最开始的问题:为什么需要"验两次"?

现在再回头看文章最开始的问题:

如果第三方支付已经验签了,为什么内部服务还需要验签?

答案其实已经比较清楚。

因为系统中存在两个不同的信任边界:

flowchart LR A[微信 / 支付宝] -->|渠道验签| B[支付服务] B -->|服务间签名| C[业务服务] A -.-> D[第一段信任关系] B -.-> E[第二段信任关系]

第一段关注的是:

第三方支付渠道发送的通知是否可信。

第二段关注的是:

业务服务收到的支付结果是否来自可信的支付服务。

所以,"验两次"并不是简单地重复做同一件事情。

它们分别对应了两个不同的信任边界。


10. 这次实习让我真正理解了什么?

需要说明的是,在实际实习中,我并没有从零实现 RSA2,也没有设计完整的支付系统。

实际工作更多是基于现有的第三方支付 SDK 和项目代码完成支付能力的接入、调用、联调,以及相关异常分支和测试处理。

但在阅读代码和处理支付流程的过程中,我开始进一步关注 SDK 背后的实现逻辑。

以前看到:

scss 复制代码
sign()
verify()

可能只会把它们理解成:

"调用一个 API 完成签名。"

后来把整个链路拆开之后,我开始意识到真正值得关注的问题其实是:

复制代码
谁信任谁?
为什么信任?
信任边界在哪里?
双方到底对什么数据进行签名?
验签失败的时候应该如何定位?

这让我对支付接入的理解从:

复制代码
调用支付 SDK

变成了:

flowchart TD A[第三方支付接入] --> B[渠道 → 支付服务] B --> C[渠道验签] C --> D[支付服务] D --> E[支付服务 → 业务服务] E --> F[服务间签名] F --> G[业务服务验签] G --> H[签名原文] H --> I[SHA-256 + RSA] I --> J[验签结果] J --> K[定位数据是否一致]

对我来说,这次经历真正值得记录下来的,并不是"我会调用某个支付 SDK"。

而是开始理解:

第三方支付已经验证过请求,并不意味着整个系统内部的所有服务都可以无条件信任这个请求。

信任关系是分层的。

而在具体排查验签问题时,真正需要关注的也不一定是复杂的密码学算法。

很多时候,更应该先问一个简单的问题:

发送方和接收方最终参与签名的数据,到底是不是完全一致?

这可能才是理解支付签名机制最重要的第一步。

最后说明:本文主要是基于实习期间接触第三方支付 SDK、阅读项目代码以及后续整理学习形成的理解,并不代表我完整实现过支付系统。

由于个人水平有限,文中如果存在理解或表述上的错误,也欢迎各位指出和讨论 🙏

相关推荐
南归北隐1 小时前
Spring AI Alibaba Graph框架实现Tools工具调用
java·后端·spring·spring ai·spring ai tools
茉莉玫瑰花茶2 小时前
GO [ 并发 · 调度器 ]
开发语言·后端·golang
zhangzeyuaaa2 小时前
深入理解 Ruby 可变对象与不可变对象的原理、坑点与最佳实践
开发语言·后端·ruby
wdfk_prog2 小时前
Wi-Fi Direct 教程 05:control socket 与 eloop——P2P_FIND 怎样进入 wpa_supplicant 命令解析器
运维·服务器·后端·网络协议·ubuntu·p2p·wifi-direct
IT_陈寒2 小时前
Python的GIL锁让我把多线程代码全重写了!
前端·人工智能·后端
马剑威(威哥爱编程)3 小时前
【AI全栈后端12-04】Spring Boot 用结构化输出自动解析简历:让模型按你的 POJO 输出
java·人工智能·spring boot·后端
我的xiaodoujiao4 小时前
Django 基础知识详细图文教程 12-Django 模型定义与使用 2
后端·python·django
孙启超5 小时前
【AI开发之Rust】第 22 课:一键多平台与工程收尾 —— CI、发布检查与结课
开发语言·后端·rust
一条小小yu12 小时前
Spring IoC的理解
java·后端·spring