签名算法与报文加密、解密算法#
本文档用于商户服务端接入支付网关 OpenAPI。接口认证使用 Bearer JWT,业务报文使用 RSA-OAEP-256 + AES-256-GCM 混合加密。SDK 包名:com.scott.payment.sdk
SDK 配置文件:merchant-config.properties
SDK 示例商户号:2606177036
SDK 示例环境:livemode=false
SDK 仓库:
Java:https://github.com/wikerx/payment-gateway-java-sdk.git
PHP:https://github.com/wikerx/payment-gateway-php-sdk.git
Go:https://github.com/wikerx/payment-gateway-go-sdk.git
本文所有请求均应由商户服务端发起。API 私钥、商户响应私钥不得放在前端、App、小程序、URL、公开日志或浏览器脚本中。
当前 OpenAPI 安全方案不需要传递密钥编号。请求和响应中的 data 均为 compact payload 五段式结构。
一、接入总览#
1
准备商户接入材料
商户需要在后台或 SDK 配置中准备以下材料:
merchantId = 2606177036
livemode = false
apiPrivateKey = API 私钥,用于生成 Bearer JWT
platformRequestPublicKey = 平台请求公钥,用于加密商户请求 data
merchantResponsePrivateKey = 商户响应私钥,用于解密平台响应 data
baseUrl = http://localhost:58060
| 字段 | 是否敏感 | 用途 | 是否出现在请求中 |
|---|
| merchantId | 是 | 标识商户,请放入 JWT Payload | 是,放在 JWT Payload |
| livemode | 是 | 区分测试环境和生产环境 | 是,JWT Payload 和 POST body 都要传 |
| apiPrivateKey | 是 | 生成 JWT HS256 签名 | 否,只参与本地签名 |
| platformRequestPublicKey | 是 | 加密请求体 data | 否,只参与本地加密 |
| merchantResponsePrivateKey | 是 | 解密响应体 data | 否,只参与本地解密 |
| baseUrl | 否 | 网关服务地址 | 是,请求地址的一部分 |
apiPrivateKey、platformRequestPublicKey 和 merchantResponsePrivateKey 是商户端密钥。即使测试环境允许商户查看,也不要写入前端工程。
2
引入 SDK
Maven 引用示例:
<dependency>
<groupId>com.scott.payment</groupId>
<artifactId>payment-gateway-java-sdk</artifactId>
<version>0.1.0-SNAPSHOT</version>
</dependency>
如果商户直接拉取源码验证,可在 SDK 根目录执行:3
配置 merchant-config.properties
SDK 默认读取 classpath 下的
merchant-config.properties。
SDK 同时支持 PEM 文件模式和文本密钥模式。两个模式任选其一即可,不要同时配置同一类密钥的 path 和文本,避免商户排查时混淆。
4
按接口类型选择调用流程
POST / PUT 等有请求体接口:
- 准备业务明文 JSON
- 使用平台请求公钥加密业务明文,生成 compact payload
- 组装请求体 {"livemode": false, "data": "{compactPayload}"}
- 使用 API 私钥生成 Bearer JWT
- 发送请求
- 使用商户响应私钥解密平台响应 data
- 按接口文档拼接 URL 和 query 参数
- 使用 API 私钥生成 Bearer JWT
- 发送请求,通常不需要 body
- 如响应 data 为密文,使用商户响应私钥解密
二、签名算法#
平台接口使用 Bearer JWT 作为接口身份认证方式。商户调用接口时,需要使用商户后台分配的 API 私钥生成 JWT,并通过 HTTP Header 传递。JWT 用于验证商户身份、请求来源、环境标识、请求有效期和防重放;业务参数加密请参考「三、报文加密、解密算法」。2.1、 生成 Authorization 请求头#
1
生成 JWT Header
JWT Header 固定使用以下结构:
{
"typ": "JWT",
"alg": "HS256"
}
| 字段 | 是否必填 | 类型 | 描述 |
|---|
| typ | Y | String | 固定为 JWT |
| alg | Y | String | 固定为 HS256,表示使用 HMAC-SHA256 签名 |
平台只接受 HS256。不要使用 none、RS256、HS512 等其他算法。
2
生成 JWT Payload
JWT Payload 需要包含商户身份、环境标识、签发时间、过期时间和唯一请求编号。
{
"iss": "merchant",
"aud": ["gateway"],
"merchantId": "2606177036",
"livemode": false,
"jti": "payment-550e8400-e29b-41d4-a716-446655440000",
"iat": 1782874330,
"exp": 1782874510
}
| 字段 | 是否必填 | 类型 | 示例 | 校验规则 |
|---|
| iss | Y | String | merchant | 必须等于 merchant |
| aud | Y | String / Array | ["gateway"] | 必须包含 gateway |
| merchantId | Y | String | 2606177036 | 必须为平台已开通商户号 |
| livemode | Y | Boolean | false | 必须为 Boolean,不能传字符串 "false" |
| jti | Y | String | payment-{uuid} | 同一商户在有效期内不得重复 |
| iat | Y | Long | 1782874330 | Unix 秒级时间戳 |
| exp | Y | Long | 1782874510 | 必须大于当前时间,建议 exp - iat <= 180 秒 |
如果 POST 请求体中也包含 livemode,则请求体中的 livemode 必须与 JWT Payload 中的 livemode 完全一致。
jti 是防重放字段。建议格式为 业务场景前缀 + "-" + UUID,例如 payment-550e8400-e29b-41d4-a716-446655440000。
3
使用 API 私钥生成 Signature
使用商户 API 私钥原文进行
HMAC-SHA256 签名。
encodedHeader = base64Url(headerJson)
encodedPayload = base64Url(payloadJson)
signingInput = encodedHeader + "." + encodedPayload
signature = base64Url(HMAC-SHA256(signingInput, apiPrivateKey))
jwt = signingInput + "." + signature
API 私钥使用原文参与签名,不需要 Base64 编码,不需要 RSA 加密,也不需要放入请求体。
Base64URL 是 JWT 标准编码方式:URL 安全、无 padding,不包含末尾 =。
4
拼接 Authorization
将
Bearer、一个英文空格和生成后的 JWT 拼接,作为请求头
Authorization 的值。
Bearer 与 JWT 之间有且仅有一个英文空格。
5
生成 X-Request-Id
建议每次请求生成一个全局唯一请求编号,用于问题排查和链路追踪。
X-Request-Id 不参与 JWT 签名,也不参与报文加密。SDK 默认会生成并放入 Header。| 名称 | 是否必填 | 类型 | 示例 | 描述 |
|---|
| Authorization | Y | String | Bearer {jwt} | Bearer JWT 鉴权信息 |
| X-Request-Id | N | String | 83196038-3d30-4b23-93fc-5997fa769455 | 请求唯一编号,建议每次请求唯一;SDK 默认生成 |
| Content-Type | 条件必填 | String | application/json; charset=UTF-8 | 有请求体的接口固定传;GET 无请求体时可不传 |
| Accept | N | String | application/json | 建议固定为 application/json |
| User-Agent | N | String | payment-gateway-java-sdk/0.1.0-SNAPSHOT java/1.8 | SDK 或调用方信息,便于平台排查 |
2.3、 JWT 验签规则#
平台收到请求后,会先从 HTTP Header 中读取 Authorization,并校验是否符合 Bearer JWT 认证格式。2.3.1、 Authorization 格式#
1.
Bearer 与 JWT 之间有且仅有一个英文空格;
2.
JWT 必须是三段式结构:header.payload.signature;
4.
JWT Header 和 Payload 只是 Base64URL 编码,不是加密,不应放入卡号、证件号、手机号、邮箱、密钥等敏感信息。
2.3.2、 JWT 三段组成#
{header}.{payload}.{signature}
| 段序号 | 参数名称 | 说明 |
|---|
| 第 1 段 | header | JWT Header,声明 JWT 类型和签名算法 |
| 第 2 段 | payload | JWT Payload,包含商户身份、环境、有效期和唯一请求编号 |
| 第 3 段 | signature | 使用 API 私钥生成的 HMAC-SHA256 签名 |
2.3.3、 Payload 字段校验说明#
| 参数 | 校验重点 | 错误影响 |
|---|
| aud | 必须包含 gateway | 不属于网关接收方,请求被拒绝 |
| iss | 必须等于 merchant | 签发方非法,请求被拒绝 |
| merchantId | 必须存在且商户状态正常 | 无法加载商户配置和密钥 |
| livemode | 必须是 Boolean | 环境标识非法;POST body 不一致也会失败 |
| iat | 必须是 Unix 秒级时间戳 | 签发时间非法 |
| exp | 必须大于当前时间 | JWT 已过期 |
| exp - iat | 建议不超过 180 秒 | 有效期过长,请求可能被拒绝 |
| jti | 同一商户有效期内不得重复 | 被识别为重放请求 |
2.3.4、 平台验签流程#
1. 从 Authorization 中提取 JWT
2. 校验 JWT 是否为 header.payload.signature 三段式结构
3. Base64URL 解码 Header,并校验 typ、alg
4. Base64URL 解码 Payload,并校验 iss、aud、merchantId、livemode、iat、exp、jti
5. 根据 merchantId 查询商户 API 私钥
6. 使用商户 API 私钥原文重新计算签名
7. 比较平台计算出的 signature 与 JWT 第三段 signature 是否一致
8. 如果一致,则验签通过;如果不一致,则拒绝请求
expectedSignature = base64Url(
HMAC-SHA256(
base64Url(headerJson) + "." + base64Url(payloadJson),
apiPrivateKey
)
)
2.4、 SDK JWT 示例代码#
src/test/java/com/scott/payment/sdk/jwt/OpenApiSignatureReferenceTest.java
生产日志不建议输出完整 JWT。SDK 示例日志会对 Authorization 做脱敏处理,便于商户核验格式同时避免误泄露。
三、报文加密、解密算法#
| 算法 | 用途 |
|---|
| AES-256-GCM | 加密业务 JSON 数据 |
| RSA-OAEP-256 | 加密 AES 对称密钥 |
| Base64URL | 编码加密后的二进制数据 |
| compact payload | 最终生成 data 字符串 |
3.1、 密钥使用关系#
请求 data 和响应 data 的加密结构完全一致,但密钥方向不同。| 场景 | 明文来源 | RSA 加密使用的公钥 | RSA 解密使用的私钥 | 解密方 |
|---|
| 请求 data | 商户请求业务参数 | 平台请求公钥 | 平台请求私钥 | 平台 |
| 响应 data | 平台响应业务数据 | 商户响应公钥 | 商户响应私钥 | 商户 |
1.
商户发送请求时,使用平台请求公钥加密请求 data;
2.
平 台收到请求后,使用平台请求私钥解密请求 data;
3.
平台返回响应时,使用商户响应公钥加密响应 data;
4.
商户收到响应后,使用商户响应私钥解密响应 data。
3.2、 data 加密结构#
请求和响应中的 data 字段均采用 compact payload 格式。compact payload 由 5 段 Base64URL 字符串组成,各段之间使用英文句点 . 拼接:protectedHeader.encryptedAesKey.iv.cipherText.tag
base64url(header).base64url(encryptedAesKey).base64url(iv).base64url(cipherText).base64url(tag)
| 序号 | 参数 | 说明 | 是否可单独查看 |
|---|
| 1 | protectedHeader | Base64URL 编码后的加密头 | 是 |
| 2 | encryptedAesKey | RSA-OAEP-256 加密后的 AES 对称密钥 | 是,排查时查看 |
| 3 | iv | AES-GCM 使用的 12 字节随机初始化向量 | 是 |
| 4 | cipherText | AES-GCM 加密后的业务密文 | 是,排查时查看 |
| 5 | tag | AES-GCM 认证标签,用于完整性校验 | 是 |
在 Java 中拆分 data 时应使用 data.split("\\.", -1),不要按未转义的英文句点拆分。英文句点在正则表达式中表示任意字符。
protectedHeader 是加密头,用于声明 data 的加密算法。{
"typ": "PAYMENT-PAYLOAD",
"alg": "RSA-OAEP-256",
"enc": "A256GCM"
}
eyJ0eXAiOiJQQVlNRU5ULVBBWUxPQUQiLCJhbGciOiJSU0EtT0FFUC0yNTYiLCJlbmMiOiJBMjU2R0NNIn0
| 字段 | 示例值 | 说明 |
|---|
| typ | PAYMENT-PAYLOAD | 表示当前 data 是支付网关加密载荷 |
| alg | RSA-OAEP-256 | 表示 AES Key 使用 RSA-OAEP-256 加密 |
| enc | A256GCM | 表示业务数据使用 AES-256-GCM 加密 |
protectedHeader 会作为 AES-GCM 的 AAD 参与完整性校验。解密时必须使用原始 protectedHeader 字符串作为 AAD。
3.4、 encryptedAesKey#
encryptedAesKey 是使用 RSA-OAEP-256 加密后的 AES 对称密钥。1. 随机生成 32 字节 AES Key
2. 使用接收方公钥通过 RSA-OAEP-256 加密 AES Key
3. 对 RSA 加密结果进行 Base64URL 编码
| 项目 | 说明 |
|---|
| 原始数据 | AES-256 对称密钥 |
| 原始长度 | 32 字节 |
| RSA transformation | RSA/ECB/OAEPPadding |
| OAEP Digest | SHA-256 |
| MGF1 Digest | SHA-256 |
| 编码方式 | Base64URL,无 padding |
SDK 使用显式 OAEPParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT)。如果商户自行实现,必须确认 OAEP 主摘要和 MGF1 摘要都使用 SHA-256。
3.5、 iv#
iv 是 AES-GCM 加密使用 的初始化向量。| 项目 | 说明 |
|---|
| 原始数据 | 随机字节序列 |
| 原始长度 | 12 字节 |
| 使用算法 | AES-GCM |
| 编码方式 | Base64URL,无 padding |
同一个 AES Key 下不得重复使用相同 IV。SDK 每次加密都会重新生成 AES Key 和 IV。
3.6、 cipherText#
cipherText 是 AES-256-GCM 加密后的业务密文。cipherText, tag = AES-256-GCM-Encrypt(
plainJson,
aesKey,
iv,
aad = protectedHeader
)
1.
请求时,cipherText 是商户请求业务参数加密后的结果;
2.
响应时,cipherText 是平台响应业务数据加密后的结果;
3.
cipherText 不包含 tag,tag 会作为第 5 段单独传输。
3.7、 tag#
tag 是 AES-GCM 生成的认证标签,用于校验数据完整性和防篡改。| 项目 | 说明 |
|---|
| 原始数据 | AES-GCM 认证标签 |
| 原始长度 | 16 字节 |
| 编码方式 | Base64URL,无 padding |
如果 protectedHeader、encryptedAesKey、iv、cipherText、tag 中任意一部分被篡改,解密时都应失败。
3.8、 数据加密核心算法代码参考如下所示#
OpenApiClientConfig config = OpenApiTestSupport.clientConfig();
OpenApiPayloadCrypto crypto = new OpenApiPayloadCrypto();
PaymentCreateRequest plainRequest = paymentCreateRequest();
String plainJson = JsonSupport.toJson(plainRequest);
OpenApiPayloadParts requestParts = crypto.encryptToParts(
plainJson,
RsaKeyUtils.readPublicKey(config.getPlatformPublicKey()));
OpenApiEncryptedRequest encryptedRequest = OpenApiEncryptedRequest.builder()
.livemode(config.getLivemode())
.data(requestParts.toCompactPayload())
.build();
OpenApiPayloadParts splitParts = crypto.splitCompactPayload(encryptedRequest.getData());
log.info("商户参考用例-请求原始明文报文: {}", JsonSupport.toLogJson(OpenApiLogSanitizer.sanitizeObject(plainRequest)));
log.info("商户参考用例-请求密文参数: {}", JsonSupport.toLogJson(encryptedRequest));
log.info("商户参考用例-请求参数拆分: {}", JsonSupport.toLogJson(splitParts));
String encryptedAesKey = requestParts.getEncryptedAesKey();
String iv = requestParts.getIv();
String cipherText = requestParts.getCipherText();
String tag = requestParts.getTag();
log.info("encryptedAesKey:{}" , encryptedAesKey);
log.info("iv:{}" , iv);
log.info("cipherText:{}" , cipherText);
log.info("tag:{}" , tag);
}
private PaymentCreateRequest paymentCreateRequest() {
Map<String, Object> card = new LinkedHashMap<String, Object>();
card.put("number", "4242424242424242");
card.put("expMonth", "06");
card.put("expYear", "2026");
card.put("cvc", "123");
PaymentCreateRequest request = new PaymentCreateRequest();
request.setOrderNo("ORDER-PAYLOAD-PARTS");
request.setCurrency("USD");
request.setAmount(OpenApiTestSupport.amount("12.34"));
request.setClientIp("47.125.221.223");
request.setWebsite("https://manage.forgottenthrone.com/");
request.setPaymentMethod("CARD");
request.setPaymentMethodData(card);
return request;
}
四、POST 请求加密流程#
POST、PUT 等有请求体的接口,需要将业务明文 JSON 加密后放入外层请求体。1
准备业务请求参数
商户先按照接口文档准备业务请求参数。
{
"orderNo": "ORDER-APIFOX-SIGNATURE",
"currency": "USD",
"amount": 12.34,
"clientIp": "47.125.221.223",
"website": "https://manage.forgottenthrone.com/",
"paymentMethod": "CARD",
"paymentMethodData": {
"number": "4242424242424242",
"expMonth": "06",
"expYear": "2026",
"cvc": "123"
}
}
卡号、CVC、证件号等敏感字段只允许出现在商户服务端内存和加密后的 data 中,不要打印完整明文到生产日志。
2
序列化业务 JSON
将业务参数序列化为 JSON 字符串。
{"orderNo":"ORDER-APIFOX-SIGNATURE","currency":"USD","amount":12.34,"clientIp":"47.125.221.223","website":"https://manage.forgottenthrone.com/","paymentMethod":"CARD","paymentMethodData":{"number":"4242424242424242","expMonth":"06","expYear":"2026","cvc":"123"}}
加密的是最终序列化后的 JSON 字符串。字段顺序不作为业务校验依据,但同一段明文由于 AES Key 和 IV 每次随机,生成的密文也不会相同。
3
生成 protectedHeader
使用固定 Header:
{
"typ": "PAYMENT-PAYLOAD",
"alg": "RSA-OAEP-256",
"enc": "A256GCM"
}
Base64URL 编码后得到 compact payload 第 1 段:4
生成 AES Key 和 IV
每次请求随机生成:
AES Key = 32 字节
IV = 12 字节
SDK 中由 OpenApiPayloadCrypto.encryptToParts(...) 自动完成。5
使用 AES-256-GCM 加密业务 JSON
使用 AES Key、IV 和
protectedHeader 作为 AAD 加密业务 JSON。
cipherText, tag = AES-256-GCM-Encrypt(
plainJson,
aesKey,
iv,
aad = protectedHeader
)
6
使用平台请求公钥加密 AES Key
使用平台请求公钥对 AES Key 进行 RSA-OAEP-256 加密。
encryptedAesKey = RSA-OAEP-256-Encrypt(aesKey, platformRequestPublicKey)
7
拼接 compact payload
将 5 段内容按顺序使用英文句点拼接:
data = protectedHeader + "." + encryptedAesKey + "." + iv + "." + cipherText + "." + tag