Skip to content

feat: 增加支持公钥验签 #219

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Feb 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 52 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,16 @@

[微信支付API v3](https://wechatpay-api.gitbook.io/wechatpay-api-v3/)的[Apache HttpClient](https://hc.apache.org/httpcomponents-client-ga/index.html)扩展,实现了请求签名的生成和应答签名的验证。

如果你是使用Apache HttpClient的商户开发者,可以使用它构造`HttpClient`。得到的`HttpClient`在执行请求时将自动携带身份认证信息,并检查应答的微信支付签名。
> [!IMPORTANT]
> 我们强烈建议你改为使用 [WechatPay-Java](https://github.com/wechatpay-apiv3/wechatpay-java),该SDK同样支持 Apache HttpClient 且提供了更完善的功能,本库未来只会进行必要的修复更新。

## 项目状态

当前版本`0.5.0`为测试版本。请商户的专业技术人员在使用时注意系统和软件的正确性和兼容性,以及带来的风险。
当前版本`0.6.0`为测试版本。请商户的专业技术人员在使用时注意系统和软件的正确性和兼容性,以及带来的风险。

## 升级指引

若你使用的版本为`0.3.0`,升级前请参考[升级指南](UPGRADING.md)。
若你使用的版本为`<=0.5.0`,升级前请参考[升级指南](UPGRADING.md)。

## 环境要求

Expand All @@ -27,7 +28,7 @@
在你的`build.gradle`文件中加入如下的依赖

```groovy
implementation 'com.github.wechatpay-apiv3:wechatpay-apache-httpclient:0.5.0'
implementation 'com.github.wechatpay-apiv3:wechatpay-apache-httpclient:0.6.0'
```

### Maven
Expand All @@ -37,17 +38,18 @@ implementation 'com.github.wechatpay-apiv3:wechatpay-apache-httpclient:0.5.0'
<dependency>
<groupId>com.github.wechatpay-apiv3</groupId>
<artifactId>wechatpay-apache-httpclient</artifactId>
<version>0.5.0</version>
<version>0.6.0</version>
</dependency>
```

## 名词解释

+ 商户API证书,是用来证实商户身份的。证书中包含商户号、证书序列号、证书有效期等信息,由证书授权机构(Certificate Authority ,简称CA)签发,以防证书被伪造或篡改。如何获取请见[商户API证书](https://wechatpay-api.gitbook.io/wechatpay-api-v3/ren-zheng/zheng-shu#shang-hu-api-zheng-shu)。
+ 商户API私钥。商户申请商户API证书时,会生成商户私钥,并保存在本地证书文件夹的文件apiclient_key.pem中。注:不要把私钥文件暴露在公共场合,如上传到Github,写在客户端代码等。
+ 微信支付平台证书。平台证书是指由微信支付负责申请的,包含微信支付平台标识、公钥信息的证书。商户可以使用平台证书中的公钥进行应答签名的验证。获取平台证书需通过[获取平台证书列表](https://wechatpay-api.gitbook.io/wechatpay-api-v3/ren-zheng/zheng-shu#ping-tai-zheng-shu)接口下载。
+ 证书序列号。每个证书都有一个由CA颁发的唯一编号,即证书序列号。如何查看证书序列号请看[这里](https://wechatpay-api.gitbook.io/wechatpay-api-v3/chang-jian-wen-ti/zheng-shu-xiang-guan#ru-he-cha-kan-zheng-shu-xu-lie-hao)。
+ API v3密钥。为了保证安全性,微信支付在回调通知和平台证书下载接口中,对关键信息进行了AES-256-GCM加密。API v3密钥是加密时使用的对称密钥。商户可以在【商户平台】->【API安全】的页面设置该密钥。
+ **商户API证书**,是用来证实商户身份的。证书中包含商户号、证书序列号、证书有效期等信息,由证书授权机构(Certificate Authority ,简称CA)签发,以防证书被伪造或篡改。如何获取请见[商户API证书](https://wechatpay-api.gitbook.io/wechatpay-api-v3/ren-zheng/zheng-shu#shang-hu-api-zheng-shu)。
+ **商户API私钥**。商户申请商户API证书时,会生成商户私钥,并保存在本地证书文件夹的文件apiclient_key.pem中。注:不要把私钥文件暴露在公共场合,如上传到Github,写在客户端代码等。
+ **微信支付平台证书**。平台证书是指由微信支付负责申请的,包含微信支付平台标识、公钥信息的证书。商户可以使用平台证书中的公钥进行应答签名的验证。获取平台证书需通过[获取平台证书列表](https://wechatpay-api.gitbook.io/wechatpay-api-v3/ren-zheng/zheng-shu#ping-tai-zheng-shu)接口下载。
+ **微信支付公钥**。由微信支付生成,商户可以使用该公钥进行应答签名、回调签名的验证,详见:[微信支付公钥](https://pay.weixin.qq.com/doc/v3/merchant/4012153196)。
+ **证书序列号**。每个证书都有一个由CA颁发的唯一编号,即证书序列号。如何查看证书序列号请看[这里](https://wechatpay-api.gitbook.io/wechatpay-api-v3/chang-jian-wen-ti/zheng-shu-xiang-guan#ru-he-cha-kan-zheng-shu-xu-lie-hao)。
+ **API v3密钥**。为了保证安全性,微信支付在回调通知和平台证书下载接口中,对关键信息进行了AES-256-GCM加密。API v3密钥是加密时使用的对称密钥。商户可以在【商户平台】->【API安全】的页面设置该密钥。

## 开始

Expand All @@ -58,7 +60,7 @@ import com.wechat.pay.contrib.apache.httpclient.WechatPayHttpClientBuilder;
//...
WechatPayHttpClientBuilder builder = WechatPayHttpClientBuilder.create()
.withMerchant(merchantId, merchantSerialNumber, merchantPrivateKey)
.withWechatPay(wechatPayCertificates);
.withWechatPay(wechatpayPublicKeyId, wechatPayPublicKey);
// ... 接下来,你仍然可以通过builder设置各种参数,来配置你的HttpClient

// 通过WechatPayHttpClientBuilder构造的HttpClient,会自动的处理签名和验签
Expand All @@ -73,7 +75,8 @@ CloseableHttpResponse response = httpClient.execute(...);
+ `merchantId`商户号。
+ `merchantSerialNumber`商户API证书的证书序列号。
+ `merchantPrivateKey`商户API私钥,如何加载商户API私钥请看[常见问题](#如何加载商户私钥)。
+ `wechatPayCertificates`微信支付平台证书列表。你也可以使用后面章节提到的“[定时更新平台证书功能](#定时更新平台证书功能)”,而不需要关心平台证书的来龙去脉。
+ `wechatpayPublicKeyId`微信支付公钥ID,登录商户平台可获取,详见:[获取微信支付公钥](https://pay.weixin.qq.com/doc/v3/merchant/4013053249#1.-%E8%8E%B7%E5%8F%96%E5%BE%AE%E4%BF%A1%E6%94%AF%E4%BB%98%E5%85%AC%E9%92%A5)
+ `wechatPayPublicKey`微信支付公钥,登录商户平台可获取,详见:[获取微信支付公钥](https://pay.weixin.qq.com/doc/v3/merchant/4013053249#1.-%E8%8E%B7%E5%8F%96%E5%BE%AE%E4%BF%A1%E6%94%AF%E4%BB%98%E5%85%AC%E9%92%A5)

### 示例:获取平台证书

Expand Down Expand Up @@ -177,11 +180,14 @@ Credentials credentials = new WechatPay2Credentials(merchantId, new Signer() {
});
WechatPayHttpClientBuilder builder = WechatPayHttpClientBuilder.create()
.withCredentials(credentials)
.withWechatPay(wechatPayCertificates);
.withWechatPay(wechatpayPublicKeyId, wechatPayPublicKey);
```

## 定时更新平台证书功能

> [!IMPORTANT]
> 新注册的商户使用「微信支付公钥」验签,不需要下载和更新平台证书。仅尚未完全迁移至「微信支付公钥」验签的旧商户才需要此能力。

版本>=`0.4.0`可使用 CertificatesManager.getVerifier(merchantId) 得到的验签器替代默认的验签器。它会定时下载和更新商户对应的[微信支付平台证书](https://wechatpay-api.gitbook.io/wechatpay-api-v3/ren-zheng/zheng-shu#ping-tai-zheng-shu) (默认下载间隔为UPDATE_INTERVAL_MINUTE)。

示例代码:
Expand All @@ -197,7 +203,7 @@ certificatesManager.putMerchant(merchantId, new WechatPay2Credentials(merchantId
verifier = certificatesManager.getVerifier(merchantId);
WechatPayHttpClientBuilder builder = WechatPayHttpClientBuilder.create()
.withMerchant(merchantId, merchantSerialNumber, merchantPrivateKey)
.withValidator(new WechatPay2Validator(verifier))
.withValidator(new WechatPay2Validator(verifier));
// ... 接下来,你仍然可以通过builder设置各种参数,来配置你的HttpClient

// 通过WechatPayHttpClientBuilder构造的HttpClient,会自动的处理签名和验签,并进行证书自动更新
Expand All @@ -217,13 +223,13 @@ CloseableHttpResponse response = httpClient.execute(...);

### 加密

使用` RsaCryptoUtil.encryptOAEP(String, X509Certificate)`进行公钥加密。示例代码如下。
使用` RsaCryptoUtil.encryptOAEP(String, PublicKey publicKey)`进行公钥加密。示例代码如下。

```java
// 建议从Verifier中获得微信支付平台证书,或使用预先下载到本地的平台证书文件中
X509Certificate certificate = verifier.getValidCertificate();
PublicKey publicKey = verifier.getValidPublicKey();
try {
String ciphertext = RsaCryptoUtil.encryptOAEP(text, certificate);
String ciphertext = RsaCryptoUtil.encryptOAEP(text, publicKey);
} catch (IllegalBlockSizeException e) {
e.printStackTrace();
}
Expand Down Expand Up @@ -277,15 +283,40 @@ try (FileInputStream ins1 = new FileInputStream(file)) {
2. 使用`NotificationHandler`构造一个回调通知处理器,需设置验证器、apiV3密钥。调用`parse(request)`得到回调通知`notification`。

示例请参考下列代码。

```java
// 构建request,传入必要参数
NotificationRequest request = new NotificationRequest.Builder().withSerialNumber(wechatPaySerial)
NotificationRequest request = new NotificationRequest.Builder().withSerialNumber(wechatPaySerial)
.withNonce(nonce)
.withTimestamp(timestamp)
.withSignature(signature)
.withBody(body)
.build();
NotificationHandler handler = new NotificationHandler(verifier, apiV3Key.getBytes(StandardCharsets.UTF_8));

// 如果已经初始化了 NotificationHandler 则直接使用,否则根据具体情况创建一个

// 1. 如果你使用的是微信支付公私钥,则使用公钥初始化 Verifier 以创建 NotificationHandler
NotificationHandler handler = new NotificationHandler(
new PublicKeyVerifier(wechatPayPublicKeyId, wechatPayPublicKey),
apiV3Key.getBytes(StandardCharsets.UTF_8)
);

// 2. 如果你使用的事微信支付平台证书,则从 CertificatesManager 获取 Verifier 以创建 NotificationHandler
NotificationHandler handler = new NotificationHandler(
certificatesManager.getVerifier(merchantId),
apiV3Key.getBytes(StandardCharsets.UTF_8)
);

// 3. 如果你正在进行微信支付平台证书到微信支付公私钥的灰度切换,希望保持切换兼容,则需要使用 MixVerifier 创建 NotificationHandler
Verifier mixVerifier = new MixVerifier(
new PublicKeyVerifier(wechatPayPublicKeyId, wechatPayPublicKey),
certificatesManager.getVerifier(merchantId)
);
NotificationHandler handler = new NotificationHandler(
mixVerifier,
apiV3Key.getBytes(StandardCharsets.UTF_8)
);

// 验签和解析请求体
Notification notification = handler.parse(request);
// 从notification中获取解密报文
Expand All @@ -306,11 +337,11 @@ System.out.println(notification.getDecryptData());
商户申请商户API证书时,会生成商户私钥,并保存在本地证书文件夹的文件`apiclient_key.pem`中。商户开发者可以使用方法`PemUtil.loadPrivateKey()`加载证书。

```java
# 示例:私钥存储在文件
// 示例:私钥存储在文件
PrivateKey merchantPrivateKey = PemUtil.loadPrivateKey(
new FileInputStream("/path/to/apiclient_key.pem"));

# 示例:私钥为String字符串
// 示例:私钥为String字符串
PrivateKey merchantPrivateKey = PemUtil.loadPrivateKey(
new ByteArrayInputStream(privateKey.getBytes("utf-8")));
```
Expand Down
4 changes: 4 additions & 0 deletions UPGRADING.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# 升级指南

## 从 0.5.0 升级至 0.6.0
`interface Verifier` 不再提供 `getValidCertificate` 接口,请换用 `getValidPublicKey` 接口。
请注意 `getValidCertificate` 与 `getValidPublicKey` 并不能等价替换,但其返回值都可以用于调用 `RsaCryptoUtil.encryptOAEP` 实现加密。

## 从 0.3.0 升级至 0.4.0

版本`0.4.0`提供了支持多商户号的[定时更新平台证书功能](README.md#定时更新平台证书功能),不兼容版本`0.3.0`。推荐升级方式如下:
Expand Down
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ plugins {
}

group 'com.github.wechatpay-apiv3'
version '0.5.0'
version '0.6.0'

sourceCompatibility = 1.8
targetCompatibility = 1.8
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.wechat.pay.contrib.apache.httpclient;

import static com.wechat.pay.contrib.apache.httpclient.constant.WechatPayHttpHeaders.WECHAT_PAY_SERIAL;
import static org.apache.http.HttpHeaders.AUTHORIZATION;
import static org.apache.http.HttpStatus.SC_MULTIPLE_CHOICES;
import static org.apache.http.HttpStatus.SC_OK;
Expand Down Expand Up @@ -81,6 +82,7 @@ private CloseableHttpResponse executeWithSignature(HttpRoute route, HttpRequestW
}
// 添加认证信息
request.addHeader(AUTHORIZATION, credentials.getSchema() + " " + credentials.getToken(request));
request.addHeader(WECHAT_PAY_SERIAL, validator.getSerialNumber());
// 执行
CloseableHttpResponse response = mainExec.execute(route, request, context, execAware);
// 对成功应答验签
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ public interface Validator {

boolean validate(CloseableHttpResponse response) throws IOException;

String getSerialNumber();
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

import com.wechat.pay.contrib.apache.httpclient.auth.CertificatesVerifier;
import com.wechat.pay.contrib.apache.httpclient.auth.PrivateKeySigner;
import com.wechat.pay.contrib.apache.httpclient.auth.PublicKeyVerifier;
import com.wechat.pay.contrib.apache.httpclient.auth.WechatPay2Credentials;
import com.wechat.pay.contrib.apache.httpclient.auth.WechatPay2Validator;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.cert.X509Certificate;
import java.util.List;
import org.apache.http.impl.client.CloseableHttpClient;
Expand Down Expand Up @@ -53,6 +55,11 @@ public WechatPayHttpClientBuilder withWechatPay(List<X509Certificate> certificat
return this;
}

public WechatPayHttpClientBuilder withWechatPay(String publicKeyId, PublicKey publicKey) {
this.validator = new WechatPay2Validator(new PublicKeyVerifier(publicKeyId, publicKey));
return this;
}

public WechatPayHttpClientBuilder withProxy(HttpHost proxy) {
if (proxy != null) {
this.setProxy(proxy);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.wechat.pay.contrib.apache.httpclient.Credentials;
import com.wechat.pay.contrib.apache.httpclient.Validator;
import com.wechat.pay.contrib.apache.httpclient.WechatPayHttpClientBuilder;
import com.wechat.pay.contrib.apache.httpclient.util.AesUtil;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.PublicKey;
import java.security.cert.CertificateExpiredException;
import java.security.cert.CertificateFactory;
import java.security.cert.CertificateNotYetValidException;
Expand Down Expand Up @@ -57,6 +59,19 @@ public class AutoUpdateCertificatesVerifier implements Verifier {
protected volatile Instant lastUpdateTime;
protected CertificatesVerifier verifier;

private static final Validator emptyValidator =
new Validator() {
@Override
public boolean validate(CloseableHttpResponse response) throws IOException {
return true;
};

@Override
public String getSerialNumber() {
return "";
}
};

public AutoUpdateCertificatesVerifier(Credentials credentials, byte[] apiV3Key) {
this(credentials, apiV3Key, TimeUnit.HOURS.toMinutes(1));
}
Expand Down Expand Up @@ -94,14 +109,19 @@ public boolean verify(String serialNumber, byte[] message, String signature) {
}

@Override
public X509Certificate getValidCertificate() {
return verifier.getValidCertificate();
public PublicKey getValidPublicKey() {
return verifier.getValidPublicKey();
}

@Override
public String getSerialNumber() {
return verifier.getSerialNumber();
}

protected void autoUpdateCert() throws IOException, GeneralSecurityException {
try (CloseableHttpClient httpClient = WechatPayHttpClientBuilder.create()
.withCredentials(credentials)
.withValidator(verifier == null ? (response) -> true : new WechatPay2Validator(verifier))
.withValidator(verifier == null ? emptyValidator : new WechatPay2Validator(verifier))
.build()) {

HttpGet httpGet = new HttpGet(CERT_DOWNLOAD_PATH);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import java.math.BigInteger;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import java.security.Signature;
import java.security.SignatureException;
import java.security.cert.CertificateExpiredException;
Expand Down Expand Up @@ -67,7 +68,6 @@ public boolean verify(String serialNumber, byte[] message, String signature) {
return verify(cert, message, signature);
}

@Override
public X509Certificate getValidCertificate() {
X509Certificate latestCert = null;
for (X509Certificate x509Cert : certificates.values()) {
Expand All @@ -83,5 +83,16 @@ public X509Certificate getValidCertificate() {
throw new NoSuchElementException("没有有效的微信支付平台证书");
}
}


@Override
public PublicKey getValidPublicKey() {
return getValidCertificate().getPublicKey();
}

@Override
public String getSerialNumber() {
return getValidCertificate().getSerialNumber().toString(16).toUpperCase();
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.wechat.pay.contrib.apache.httpclient.auth;


import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.security.PublicKey;
import java.util.Objects;


/**
* MixVerifier 混合Verifier,仅用于切换平台证书与微信支付公钥时提供兼容
*
* 本实例需要使用一个 PublicKeyVerifier + 一个 Verifier 初始化,前者提供微信支付公钥验签,后者提供平台证书验签
*/
public class MixVerifier implements Verifier {
private static final Logger log = LoggerFactory.getLogger(MixVerifier.class);

final PublicKeyVerifier publicKeyVerifier;
final Verifier certificateVerifier;

public MixVerifier(PublicKeyVerifier publicKeyVerifier, Verifier certificateVerifier) {
if (publicKeyVerifier == null) {
throw new IllegalArgumentException("publicKeyVerifier cannot be null");
}

this.publicKeyVerifier = publicKeyVerifier;
this.certificateVerifier = certificateVerifier;
}

@Override
public boolean verify(String serialNumber, byte[] message, String signature) {
if (Objects.equals(publicKeyVerifier.getSerialNumber(), serialNumber)) {
return publicKeyVerifier.verify(serialNumber, message, signature);
}

if (certificateVerifier != null) {
return certificateVerifier.verify(serialNumber, message, signature);
}

log.error("找不到证书序列号对应的证书,序列号:{}", serialNumber);
return false;
}

@Override
public PublicKey getValidPublicKey() {
return publicKeyVerifier.getValidPublicKey();
}

@Override
public String getSerialNumber() {
return publicKeyVerifier.getSerialNumber();
}
}
Loading