Skip to content

Commit

Permalink
feat: 增加支持公钥验签 (#219)
Browse files Browse the repository at this point in the history
  • Loading branch information
EmmetZC authored Feb 11, 2025
1 parent 1fab366 commit d60ea25
Show file tree
Hide file tree
Showing 18 changed files with 360 additions and 69 deletions.
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

0 comments on commit d60ea25

Please sign in to comment.