posts

sign-plugin-验证手册

ShenYu 加签验签插件验证手册,基于 RSA-SHA256 实现支付级请求签名校验。

ShenYu 加签验签验证手册(SignPlugin × RequestPlugin × DividePlugin)

适用版本:ShenYu admin/bootstrap 2.6.x ~ 2.7.x(网关侧),客户端侧基于本仓库 shenyu-client-coresign 包。
协议基准:支付服务(微信支付 V3 风格)—— RSA-SHA256 非对称签名。

本手册与 divide-plugin-验证手册.md 配套:divide 关注"转发",本手册关注"安全"。


一、需求背景与协议总览

1.1 什么时候需要加签与验签

业务系统调用支付接口时,双方需基于非对称密钥互相验证身份与报文完整性,并防止重放。
每个参与方各持有一对 RSA 密钥(自己的私钥 + 自己的公钥),并把自己的公钥交给对方保管:

参与方 自己持有(用于加签) 保管对方的(用于验签)
业务系统(BIZ) 业务系统私钥 支付服务公钥
支付服务(PAY) 支付服务私钥 业务系统公钥

关键原则:私钥永不离开持有方,只在本地加签时使用;公钥交给对方,对方用来验签。

一次完整交易是串联的两段(不是三个独立场景),时序如下:

  1. 请求段(BIZ → PAY):业务系统发起支付请求,用【业务系统私钥】对请求加签生成 sign;
    支付服务收到后用【业务系统公钥】验签。验签通过后才会处理业务
  2. 响应段(PAY → BIZ):支付服务处理完业务,用【支付服务私钥】对响应数据加签生成 sign,连同响应一起返回;
    业务系统收到后用【支付服务公钥】验签响应。
  3. 回调段(PAY → BIZ,可选,异步):支付服务在交易状态变化时,用【支付服务私钥】对回调通知加签,
    POST 给业务系统的回调地址;业务系统用【支付服务公钥】验签。验签逻辑与响应段一致。
场景 加签方 加签所用私钥 验签方 验签所用公钥
请求(BIZ→PAY) 业务系统 业务系统私钥 支付服务 业务系统公钥
响应(PAY→BIZ) 支付服务 支付服务私钥 业务系统 支付服务公钥
回调通知(PAY→BIZ) 支付服务 支付服务私钥 业务系统 支付服务公钥

1.2 协议要素(本仓库 shenyu-client-coresign 包已实现)

要素 约定
传输 HTTPS(请求时不忽略证书校验)
数据格式 JSON(UTF-8),Content-Type: application/json; charset=utf-8
签名算法 SHA256withRSA(标准 JCA,JDK8 原生支持)
加签结果 Base64 编码字符串
HTTP 头 X-Pay-Timestamp(秒级)/ X-Pay-Nonce(32位hex)/ X-Pay-Sign(签名值)

1.3 待签名串格式

请求加签(5 行格式,每行 \n 结尾,含最后一行)

HTTP请求方法\n
URL\n                              ← 绝对路径,不含 scheme/host;GET 含完整 query string
请求时间戳\n
请求随机串\n
请求报文主体\n                       ← GET 为空行

响应/回调验签(3 行格式)

应答时间戳\n
应答随机串\n
应答报文主体\n                       ← 204 时仅为一个 \n

字段名与算法常量定义见 org.apache.shenyu.client.core.sign.SignConstants


二、本仓库已交付的客户端能力

客户端侧工具位于 shenyu-client-core 模块的 org.apache.shenyu.client.core.sign 包(6 个类):

职责
SignConstants 协议常量(Header 名 / 算法 / 分隔符)
PemUtils PEM 密钥加载(PKCS#8 私钥 / X.509 公钥)
SignStringBuilder 待签名串构造(请求 5 行 / 响应 3 行)
RsaSigner SHA256withRSA 加签与验签(标准 JCA)
PaySignInterceptor OkHttp 出站请求自动加签(业务系统侧)
PaySignVerifier 响应验签 / 回调验签 / 入站请求验签 + 防重放

正确性已验证

  • SignStringBuilderTest:用需求文档 4 个请求示例 + 响应示例逐一断言输出完全一致。
  • RsaSignerTest:round-trip(运行时生成密钥对加签验签)+ 跨工具一致性锚点(OpenSSL 生成的真实密钥+签名,Java verify 与 OpenSSL dgst -verify 均为 “Verified OK”)。
  • Demo shenyu-sign-demo:本地闭环跑通请求加签→请求验签→响应加签→响应验签→回调加签→回调验签。

说明:需求文档示例公开的公钥与签名值经 OpenSSL 直接验证为 Verification failure(两者非同一密钥对产生),故测试改用可复现的 OpenSSL 真实匹配数据作为锚点;签名串格式与文档完全一致。


三、三插件协作架构(网关侧)

注意:SignPlugin / RequestPlugin / DividePlugin 的实现位于 apache/shenyu 主仓库(网关侧),本客户端仓库不含其源码。本节描述它们如何协作承载支付加签验签。

3.1 架构与执行顺序

┌─────────────┐    ┌──────────────┐    ┌────────────────┐    ┌──────────────┐
│  SignPlugin │───▶│RequestPlugin │───▶│  ContextPath   │───▶│ DividePlugin │
│ (前置治理)  │    │  (改头/透传)  │    │   Plugin       │    │ (末端转发)   │
│ • 验签请求  │    │ • 注入/移除头 │    │ • 路径剥离     │    │ • 路由匹配   │
│ • 防重放    │    │ • 动态值(SpEL)│    │               │    │ • 负载均衡   │
└─────────────┘    └──────────────┘    └────────────────┘    └──────────────┘
       ▲                   ▲                                        │
       │                   │                                        ▼
  Admin 热推送         Admin 热推送                              WebClient
  (密钥/算法)          (Header 规则)                             (HTTP 转发)

插件 getOrder() 默认顺序(切勿手动调乱):

SignPlugin(50) < RequestPlugin(100) < DividePlugin(200)

❌ 错误顺序:Divide → Sign → Request(Divide 已转发,后续插件无法拦截)
✅ 正确顺序:Sign → Request → Divide(先验签、再改头、最后转发)

3.2 三大场景时序

场景一:请求加签 + 请求验签(对应需求文档「加签」时序图)

sequenceDiagram
    participant BIZ as 业务系统
    participant GW as ShenYu 网关(SignPlugin)
    participant PAY as 支付服务
    BIZ->>BIZ: 用业务私钥对请求参数签名(5行格式)
    BIZ->>GW: HTTP请求 + X-Pay-Timestamp/Nonce/Sign
    GW->>GW: SignPlugin 用业务公钥验签(防重放)
    GW->>PAY: RequestPlugin透传 + Divide转发

场景二:响应加签 + 响应验签(对应需求文档「验签」时序一)

sequenceDiagram
    participant BIZ as 业务系统
    participant GW as ShenYu 网关
    participant PAY as 支付服务
    PAY->>PAY: 用支付私钥对响应签名(3行格式)
    PAY->>GW: HTTP响应 + X-Pay-* 头
    GW->>BIZ: Divide回传响应
    BIZ->>BIZ: 用支付公钥验签响应

场景三:回调通知加签 + 验签(对应需求文档「验签」时序二)

sequenceDiagram
    participant BIZ as 业务系统
    participant PAY as 支付服务
    PAY->>PAY: 用支付私钥对回调通知签名(3行格式)
    PAY->>BIZ: HTTP回调 + X-Pay-* 头
    BIZ->>BIZ: 用支付公钥验签回调

场景二、三的验签在业务系统侧完成(本仓库 PaySignVerifier),可不经网关。


四、网关侧自定义验签 SPI 代码模板(apache/shenyu 主仓库)

ShenYu 原生 SignPlugin 默认用 AES 对称签名。要承载本套 RSA 协议,需在网关侧实现自定义 SignService SPI。

4.1 实现 SignService(网关侧,主仓库 shenyu-plugin-sign 模块)

package org.apache.shenyu.plugin.sign.custom;

import org.apache.shenyu.plugin.sign.api.SignService;
import org.apache.shenyu.plugin.sign.api.SignContext;
import org.apache.shenyu.spi.Join;
import java.security.PublicKey;
import java.util.Base64;
import java.security.Signature;

/**
 * 微信支付 V3 风格 RSA-SHA256 验签 SPI。
 * 与客户端 org.apache.shenyu.client.core.sign.RsaSigner 的 verify 逻辑完全一致。
 */
@Join
public class PayRsaSignService implements SignService {

    @Override
    public boolean signVerify(final byte[] body, final SignContext signContext) {
        try {
            // 1. 取业务系统公钥(按 appKey 从配置/缓存查)
            PublicKey publicKey = resolvePublicKey(signContext.getAppKey());
            // 2. 构造 5 行待签名串:method\nurl\nts\nnonce\nbody\n
            String signString = buildRequestSignString(signContext, new String(body));
            // 3. SHA256withRSA 验签
            byte[] sig = Base64.getDecoder().decode(signContext.getSign());
            Signature verifier = Signature.getInstance("SHA256withRSA");
            verifier.initVerify(publicKey);
            verifier.update(signString.getBytes("UTF-8"));
            return verifier.verify(sig);
        } catch (Exception e) {
            return false;
        }
    }

    private String buildRequestSignString(final SignContext ctx, final String body) {
        return ctx.getHttpMethod() + "\n"
                + ctx.getPath() + "\n"        // 含 query string
                + ctx.getTimestamp() + "\n"
                + ctx.getNonce() + "\n"
                + (body == null ? "" : body) + "\n";
    }

    private PublicKey resolvePublicKey(final String appKey) {
        // TODO: 按 appKey 查业务系统公钥(从 Admin 配置或缓存)
        throw new UnsupportedOperationException("按实际密钥管理实现");
    }
}

4.2 SPI 声明

在网关 bootstrap 的 classpath 下创建:

META-INF/shenyu/org.apache.shenyu.plugin.sign.api.SignService

内容:

payRsa=org.apache.shenyu.plugin.sign.custom.PayRsaSignService

4.3 SignPlugin 头字段适配

ShenYu 原生从 ShenYu-Authorization(v2) 或 appKey/sign/version(v1) 头取参。本套协议用 X-Pay-* 头,需确保 SignPlugin 读取头时映射到 X-Pay-Timestamp / X-Pay-Nonce / X-Pay-Sign(修改 SignPlugin 的头提取逻辑,或在 RequestPlugin 中提前把 X-Pay-* 改写为原生头名)。


五、RequestPlugin 配置示例(Admin)

用于注入链路追踪、透传签名头等:

Selector(选择器,匹配 /v3/pay/**):

{
  "name": "/v3/pay/**",
  "pluginName": "request",
  "matchMode": 0,
  "type": 1,
  "sort": 100,
  "enabled": true,
  "conditionDataList": [
    {"paramType": "uri", "operator": "match", "paramValue": "/v3/pay/**"}
  ]
}

Rule Handle(请求头处理):

{
  "header": {
    "addHeaders": {
      "X-Trace-Id": "${req.header.X-Trace-Id}"
    },
    "setHeaders": {
      "Content-Type": "application/json; charset=utf-8"
    },
    "removeHeaders": ["X-Internal-Token"]
  }
}

六、DividePlugin 转发配置示例(Admin)

详细字段见 divide-plugin-验证手册.md。此处仅给出最小可用配置。

Selector(选择器):

{
  "name": "/v3/pay",
  "pluginName": "divide",
  "type": 1,
  "matchMode": 0,
  "sort": 200,
  "enabled": true,
  "conditionDataList": [
    {"paramType": "uri", "operator": "match", "paramValue": "/v3/pay/**"}
  ]
}

Rule Handle(转发参数):

{
  "loadBalance": "roundRobin",
  "retry": 1,
  "timeout": 3000,
  "headerMaxSize": 10240,
  "requestMaxSize": 10240,
  "upstream": [
    {"protocol": "http://", "upstreamHost": "127.0.0.1", "upstreamUrl": "127.0.0.1:8390", "weight": 50}
  ]
}

七、风险与避坑指南

7.1 Body 参与签名需缓存请求体

WebFlux 的请求体只能读取一次。SignPlugin 验签需读 body,DividePlugin 转发也要读 body,会冲突。

解决方案:在 SignPlugin 之前启用 CacheRequestBodyPlugin(缓存请求体供多次读取)。注意内存开销,仅对需要验签的路由开启。

7.2 插件顺序不可调乱

SignPlugin 必须在 DividePlugin 之前,否则请求已被转发,验签无法拦截。默认 getOrder() 顺序正确,切勿手动改为 Divide < Sign

7.3 RequestPlugin 改头与签名字段的冲突

若 RequestPlugin 添加的 Header 恰好参与签名计算(如 X-Pay-Sign 本身),会导致下游验签失败。

原则:RequestPlugin 添加的头(如 X-Trace-Id)不应进入签名串;签名相关的 X-Pay-* 头由加签方产生,RequestPlugin 只透传不修改。

7.4 现有 JWT 注册鉴权互不干扰

本客户端仓库现有的 X-Access-Token(JWT)是注册/心跳到 ShenYu Admin 的鉴权,与支付业务的 X-Pay-* 签名是两条独立链路

  • RegisterUtilsshenyu-client-core)→ Admin:JWT 鉴权,不变。
  • 业务系统 → 网关 → 支付服务:X-Pay-* 签名,本手册主题。

两者 Header 名不同、用途不同,互不影响。

7.5 时间戳防重放

所有验签都应校验 X-Pay-Timestamp 与服务器时间差(建议 ±300 秒)。本仓库 PaySignVerifier.checkTimestamp 已实现,Demo PayService / BizController 均已启用。

7.6 响应验签必须用原始报文

若 Web 框架(如某些 Jackson 配置)重新格式化响应 JSON(改变字段顺序/空格),会导致响应验签失败。验签务必使用原始报文字节。本仓库 PaySignVerifier.verifyResponse 直接读取 OkHttp 响应字节流,不经过反序列化。


八、多方论证小结:为什么选微信支付 V3 协议

方案 算法 签名串 契合本需求 备注
微信支付 V3(本方案) SHA256withRSA 方法\nURL\nts\nnonce\nbody\n ✅ 完全对应 非对称、防篡改、防重放
ShenYu 原生 SignPlugin AES(对称) appKey+ts+nonce+path ❌ 算法/字段均不同 需 SPI 改造为 RSA
Alipay SDK RSA2 sorted params + body ⚠️ 字段体系不同 适合支付宝场景
AWS Sig V4 HMAC 派生 key 多步派生 ❌ 过重 适合 AWS 云服务

选型理由:需求文档明确为支付场景 + RSA-SHA256 + X-Pay-* 头 + 5 行/3 行签名串格式,与微信支付 V3 完全对应,故严格采用该协议。ShenYu 网关侧通过自定义 SignService SPI 承接(第四章),客户端侧用本仓库 sign 包(第二章)。


九、Demo 快速验证

本仓库 shenyu-sign-demo 模块提供本地闭环验证(不依赖真实网关):

cd shenyu-sign-demo
mvn spring-boot:run

另开终端:

# 场景一+二:请求加签→请求验签→响应加签→响应验签
curl http://localhost:8390/biz/pay

# 场景三:回调通知加签→回调验签
curl -X POST http://localhost:8390/v3/pay/notify-trigger

控制台日志会打印每个环节的签名串、签名值、验签结果,便于核对。

密钥文件位于 shenyu-sign-demo/src/main/resources/keys/(仅供 Demo,禁止生产使用)。

十、网关验签失败返回真实 HTTP 401

网关验签失败返回真实 HTTP 401
需要在 PayRsaSignService 里直接操作 exchange 的响应状态(但这绕过了ShenYu的标准错误返回机制,需谨慎)。标准做法是接受「HTTP 200 + body code:401」——这也是微信支付 V3、支付宝等主流支付网关的惯例(业务错误用 body code 区分,传输层保持 200,便于客户端统一解析)。

实现:网关验签失败返回真实 HTTP 401

实现方式

PayRsaSignService 里封装了 fail401(exchange, reason) 辅助方法,所有验签失败出口统一调用:

private VerifyResult fail401(final ServerWebExchange exchange, final String reason) {
    exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
    return VerifyResult.fail(reason);
}

原理(为什么这样能生效)

PayRsaSignService.signatureVerify()
    └─ 验签失败 → fail401(exchange, reason)
         ├─ exchange.getResponse().setStatusCode(401)   ← 此刻 response 未 committed
         └─ return VerifyResult.fail(reason)
SignPlugin.doExecute() 检测 isFailed()
    └─ WebFluxResultUtils.failedResult(401, reason, exchange)
         └─ WebFluxResultUtils.result(exchange, error)
              └─ exchange.getResponse().writeWith(body)   ← writeWith 沿用已设的 401

关键时序:setStatusCode(401) 发生在 SignService 返回前,早于 WebFluxResultUtils.result() 写 body。Spring WebFlux 的 writeWith 会使用 response 上已设置的状态码(401),而 WebFluxResultUtils.result() 本身不覆盖状态码,所以 401 得以保留。

改动前后对比(实测)

请求 改动前 改动后
无签名头 POST 网关 HTTP/1.1 200 OK + body {"code":401,...} HTTP/1.1 401 Unauthorized + body {"code":401,...}
正常带签名请求 HTTP/1.1 200 HTTP/1.1 200(不受影响)

涉及的 4 个失败出口(全部已改为真实 401)

  1. 缺少签名头(missing X-Pay-* header
  2. 时间戳格式错误(invalid timestamp format
  3. 时间戳过期(timestamp expired
  4. 签名校验失败(sign verify failed)+ 验签异常(verify error

网关现已部署新版 jar,BIZ/PAY 无需改动即可生效。后续任何验签失败的请求,网关都会直接返回真实 HTTP 401,客户端可用 HTTP 状态码做传输层错误判断,同时 body 里的 code:401 仍保留供业务层使用。

{
    "步骤3_BIZ响应验签结果": "通过(PAY公钥验签成功)",
    "步骤2_PAY响应体": "{\"code\":\"SUCCESS\",\"data\":{\"trade_no\":\"db3020c7cbbf4a9bbace610195300f19\",\"prepay_id\":\"wxdb3020c7cbbf4a9b\"},\"message\":\"ok\"}",
    "步骤2_PAY处理并加签响应_httpCode": 200,
    "步骤1_请求体": "{\"appid\":\"wxd678efh567hg6787\",\"mchid\":\"1900007291\",\"description\":\"Image形象店-深圳腾大-QQ公仔\",\"out_trade_no\":\"1217752501201407033233368018\",\"amount\":{\"total\":100,\"currency\":\"CNY\"}}",
    "目标URL": "http://localhost:9196/pay-demo/v3/pay/transactions/jsapi",
    "步骤2_响应签名头": "ts=1782954807 nonce=49472b7107ae4ba8b953f37c28f3c089",
    "步骤1_BIZ加签全过程_私钥加密": {
        "算法": "SHA256withRSA(业务私钥加密)",
        "待签名串_5行_换行替换": "POST↩/pay-demo/v3/pay/transactions/jsapi↩1782954807↩cecb5b33440148c4a73b3422e62958d2↩{\"appid\":\"wxd678efh567hg6787\",\"mchid\":\"1900007291\",\"description\":\"Image形象店-深圳腾大-QQ公仔\",\"out_trade_no\":\"1217752501201407033233368018\",\"amount\":{\"total\":100,\"currency\":\"CNY\"}}↩",
        "method": "POST",
        "签名值_sign": "ghyVWQbBoEMw22RAE86TnLjpQWLX6Gxz+DRdmsxrMo+dqTOZDeRaMj+8qN76Gi9WAu1n0H1xdTon4zpNZSXzvLziM3Z5RegjnKzfqZ5Vc56wb+mXfpESd4Kg08k9+0kFU27+ko5+KYcOoStlJbRj+1zfsu8b9212Fm8vXziqkHwN5ATJUaEPM+FNmsrJuuPjhe4ryEzWt26VR2IYzn7xHMmr2KPDCvh1bDDdOqeQZ8YKNWYKb3ZiDCeeVH7iCkos5gZV/NRproKPW2bAYas16qcmc3BmQ984Yg+R4JG+TXXYAPqV/mPJy2ovLnfPmmoAh5yoMwP5Wl0VgiD+L8Z8Vg==",
        "注入请求头": "X-Pay-Timestamp / X-Pay-Nonce / X-Pay-Sign",
        "nonce": "cecb5b33440148c4a73b3422e62958d2",
        "url": "/pay-demo/v3/pay/transactions/jsapi",
        "timestamp": "1782954807"
    }
}
{
    "步骤3_BIZ响应验签结果": "失败",
    "步骤2_PAY响应体": "{\"code\":401,\"message\":\"verify error: Input byte array has incorrect ending byte at 344\"}",
    "步骤2_PAY处理并加签响应_httpCode": 401,
    "步骤1_请求体": "{\"appid\":\"wxd678efh567hg6787\",\"mchid\":\"1900007291\",\"description\":\"Image形象店-深圳腾大-QQ公仔\",\"out_trade_no\":\"1217752501201407033233368018\",\"amount\":{\"total\":100,\"currency\":\"CNY\"}}",
    "目标URL": "http://localhost:9196/pay-demo/v3/pay/transactions/jsapi",
    "步骤2_响应签名头": "无(验签失败或非加签响应)",
    "步骤1_BIZ加签全过程_私钥加密": {
        "算法": "SHA256withRSA(业务私钥加密)",
        "待签名串_5行_换行替换": "POST↩/pay-demo/v3/pay/transactions/jsapi↩1782954970↩5b10eb9f2c524f0f92e0c5b2f2c62b99↩{\"appid\":\"wxd678efh567hg6787\",\"mchid\":\"1900007291\",\"description\":\"Image形象店-深圳腾大-QQ公仔\",\"out_trade_no\":\"1217752501201407033233368018\",\"amount\":{\"total\":100,\"currency\":\"CNY\"}}↩",
        "method": "POST",
        "签名值_sign": "uJcSgY0KNwygye64+PsnvHVMahFnXKA/Zw9RZxhb3WO1/+SyQZDR7+tx+lsWdpZ4nbeH1Bj1oG23BqxE6AVMx5IjqVDanb0UKhxFbMQvxx+G0/eFfw6ztIrBBOtGthiIm9JP4rQiM0DdgAJ5GWzAwKvEeMKNVcw3yPbJLnzrxOW2ceHHjo8RBz9+BMCKZ2Hg77JMwMA7ISexmqkDtxB/5Q5JV50UpM5LyzOSQyC0t6wUocaCCrP2f3qYh0pnbFqs637Kf0qS7ARejslo73pOJ+sCVJQgYWL5hEMTSOL9cuR9A2S+FNXnAyHjf6ajyh5iPMMU+Th96hXzGum9UamyEw==",
        "注入请求头": "X-Pay-Timestamp / X-Pay-Nonce / X-Pay-Sign",
        "nonce": "5b10eb9f2c524f0f92e0c5b2f2c62b99",
        "url": "/pay-demo/v3/pay/transactions/jsapi",
        "timestamp": "1782954970"
    }
}