From 11617ead83761e001247a45d84fc67f6cfe2031a Mon Sep 17 00:00:00 2001 From: EmmetZC Date: Tue, 11 Feb 2025 19:34:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=85=AC=E9=92=A5=E9=AA=8C=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 73 +++++++++++++------ UPGRADING.md | 4 + build.gradle | 2 +- .../apache/httpclient/SignatureExec.java | 2 + .../contrib/apache/httpclient/Validator.java | 1 + .../WechatPayHttpClientBuilder.java | 7 ++ .../auth/AutoUpdateCertificatesVerifier.java | 26 ++++++- .../httpclient/auth/CertificatesVerifier.java | 13 +++- .../apache/httpclient/auth/MixVerifier.java | 54 ++++++++++++++ .../httpclient/auth/PublicKeyVerifier.java | 44 +++++++++++ .../apache/httpclient/auth/Verifier.java | 31 ++++++-- .../httpclient/auth/WechatPay2Validator.java | 5 ++ .../httpclient/cert/CertificatesManager.java | 30 +++++++- .../apache/httpclient/util/PemUtil.java | 33 +++++++++ .../apache/httpclient/util/RsaCryptoUtil.java | 11 ++- .../httpclient/HttpClientBuilderTest.java | 29 ++------ .../httpclient/NotificationHandlerTest.java | 58 +++++++++++++-- .../apache/httpclient/RsaCryptoTest.java | 6 +- 18 files changed, 360 insertions(+), 69 deletions(-) create mode 100644 src/main/java/com/wechat/pay/contrib/apache/httpclient/auth/MixVerifier.java create mode 100644 src/main/java/com/wechat/pay/contrib/apache/httpclient/auth/PublicKeyVerifier.java diff --git a/README.md b/README.md index 4824d4b..f90673c 100644 --- a/README.md +++ b/README.md @@ -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)。 ## 环境要求 @@ -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 @@ -37,17 +38,18 @@ implementation 'com.github.wechatpay-apiv3:wechatpay-apache-httpclient:0.5.0' com.github.wechatpay-apiv3 wechatpay-apache-httpclient - 0.5.0 + 0.6.0 ``` ## 名词解释 -+ 商户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安全】的页面设置该密钥。 ## 开始 @@ -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,会自动的处理签名和验签 @@ -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) ### 示例:获取平台证书 @@ -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)。 示例代码: @@ -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,会自动的处理签名和验签,并进行证书自动更新 @@ -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(); } @@ -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中获取解密报文 @@ -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"))); ``` diff --git a/UPGRADING.md b/UPGRADING.md index 7003831..6e1a7c2 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -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`。推荐升级方式如下: diff --git a/build.gradle b/build.gradle index ebe0fa5..7cec3c9 100644 --- a/build.gradle +++ b/build.gradle @@ -5,7 +5,7 @@ plugins { } group 'com.github.wechatpay-apiv3' -version '0.5.0' +version '0.6.0' sourceCompatibility = 1.8 targetCompatibility = 1.8 diff --git a/src/main/java/com/wechat/pay/contrib/apache/httpclient/SignatureExec.java b/src/main/java/com/wechat/pay/contrib/apache/httpclient/SignatureExec.java index 0ca0ae9..092dd6a 100644 --- a/src/main/java/com/wechat/pay/contrib/apache/httpclient/SignatureExec.java +++ b/src/main/java/com/wechat/pay/contrib/apache/httpclient/SignatureExec.java @@ -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; @@ -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); // 对成功应答验签 diff --git a/src/main/java/com/wechat/pay/contrib/apache/httpclient/Validator.java b/src/main/java/com/wechat/pay/contrib/apache/httpclient/Validator.java index 4a56811..758f930 100644 --- a/src/main/java/com/wechat/pay/contrib/apache/httpclient/Validator.java +++ b/src/main/java/com/wechat/pay/contrib/apache/httpclient/Validator.java @@ -10,4 +10,5 @@ public interface Validator { boolean validate(CloseableHttpResponse response) throws IOException; + String getSerialNumber(); } diff --git a/src/main/java/com/wechat/pay/contrib/apache/httpclient/WechatPayHttpClientBuilder.java b/src/main/java/com/wechat/pay/contrib/apache/httpclient/WechatPayHttpClientBuilder.java index 2fd3c21..c67529c 100644 --- a/src/main/java/com/wechat/pay/contrib/apache/httpclient/WechatPayHttpClientBuilder.java +++ b/src/main/java/com/wechat/pay/contrib/apache/httpclient/WechatPayHttpClientBuilder.java @@ -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; @@ -53,6 +55,11 @@ public WechatPayHttpClientBuilder withWechatPay(List 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); diff --git a/src/main/java/com/wechat/pay/contrib/apache/httpclient/auth/AutoUpdateCertificatesVerifier.java b/src/main/java/com/wechat/pay/contrib/apache/httpclient/auth/AutoUpdateCertificatesVerifier.java index cf4987c..f607c3c 100644 --- a/src/main/java/com/wechat/pay/contrib/apache/httpclient/auth/AutoUpdateCertificatesVerifier.java +++ b/src/main/java/com/wechat/pay/contrib/apache/httpclient/auth/AutoUpdateCertificatesVerifier.java @@ -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; @@ -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)); } @@ -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); diff --git a/src/main/java/com/wechat/pay/contrib/apache/httpclient/auth/CertificatesVerifier.java b/src/main/java/com/wechat/pay/contrib/apache/httpclient/auth/CertificatesVerifier.java index 681d925..5e52f31 100644 --- a/src/main/java/com/wechat/pay/contrib/apache/httpclient/auth/CertificatesVerifier.java +++ b/src/main/java/com/wechat/pay/contrib/apache/httpclient/auth/CertificatesVerifier.java @@ -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; @@ -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()) { @@ -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(); + } } diff --git a/src/main/java/com/wechat/pay/contrib/apache/httpclient/auth/MixVerifier.java b/src/main/java/com/wechat/pay/contrib/apache/httpclient/auth/MixVerifier.java new file mode 100644 index 0000000..59dfb85 --- /dev/null +++ b/src/main/java/com/wechat/pay/contrib/apache/httpclient/auth/MixVerifier.java @@ -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(); + } +} diff --git a/src/main/java/com/wechat/pay/contrib/apache/httpclient/auth/PublicKeyVerifier.java b/src/main/java/com/wechat/pay/contrib/apache/httpclient/auth/PublicKeyVerifier.java new file mode 100644 index 0000000..8ff2d66 --- /dev/null +++ b/src/main/java/com/wechat/pay/contrib/apache/httpclient/auth/PublicKeyVerifier.java @@ -0,0 +1,44 @@ +package com.wechat.pay.contrib.apache.httpclient.auth; + +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.Signature; +import java.security.SignatureException; +import java.util.Base64; + +public class PublicKeyVerifier implements Verifier { + protected final PublicKey publicKey; + protected final String publicKeyId; + + public PublicKeyVerifier(String publicKeyId, PublicKey publicKey) { + this.publicKey = publicKey; + this.publicKeyId = publicKeyId; + } + + @Override + public boolean verify(String serialNumber, byte[] message, String signature) { + try { + Signature sign = Signature.getInstance("SHA256withRSA"); + sign.initVerify(publicKey); + sign.update(message); + return sign.verify(Base64.getDecoder().decode(signature)); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("当前Java环境不支持SHA256withRSA", e); + } catch (SignatureException e) { + throw new RuntimeException("签名验证过程发生了错误", e); + } catch (InvalidKeyException e) { + throw new RuntimeException("无效的证书", e); + } + } + + @Override + public PublicKey getValidPublicKey() { + return publicKey; + } + + @Override + public String getSerialNumber() { + return publicKeyId; + } +} diff --git a/src/main/java/com/wechat/pay/contrib/apache/httpclient/auth/Verifier.java b/src/main/java/com/wechat/pay/contrib/apache/httpclient/auth/Verifier.java index 9d954bb..9d9346f 100644 --- a/src/main/java/com/wechat/pay/contrib/apache/httpclient/auth/Verifier.java +++ b/src/main/java/com/wechat/pay/contrib/apache/httpclient/auth/Verifier.java @@ -1,18 +1,37 @@ package com.wechat.pay.contrib.apache.httpclient.auth; -import java.security.cert.X509Certificate; +import java.security.PublicKey; /** * @author xy-peng */ public interface Verifier { - + /** + * @param serialNumber 微信支付序列号(微信支付公钥ID 或 平台证书序列号) + * @param message 验签的原文 + * @param signature 验签的签名 + * @return 验证是否通过 + */ boolean verify(String serialNumber, byte[] message, String signature); /** - * 获取合法的平台证书 - * - * @return 合法证书 + * 获取合法的公钥,针对不同的验签模式有所区别 + * + * @return 合法公钥 + */ + PublicKey getValidPublicKey(); + + + /** + * 获取微信支付序列号,针对不同的验签模式有所区别: + * + * @return 微信支付序列号 */ - X509Certificate getValidCertificate(); + String getSerialNumber(); } diff --git a/src/main/java/com/wechat/pay/contrib/apache/httpclient/auth/WechatPay2Validator.java b/src/main/java/com/wechat/pay/contrib/apache/httpclient/auth/WechatPay2Validator.java index b265db8..d9e8b95 100644 --- a/src/main/java/com/wechat/pay/contrib/apache/httpclient/auth/WechatPay2Validator.java +++ b/src/main/java/com/wechat/pay/contrib/apache/httpclient/auth/WechatPay2Validator.java @@ -67,6 +67,11 @@ public final boolean validate(CloseableHttpResponse response) throws IOException return true; } + @Override + public final String getSerialNumber() { + return verifier.getSerialNumber(); + } + protected final void validateParameters(CloseableHttpResponse response) { Header firstHeader = response.getFirstHeader(REQUEST_ID); if (firstHeader == null) { diff --git a/src/main/java/com/wechat/pay/contrib/apache/httpclient/cert/CertificatesManager.java b/src/main/java/com/wechat/pay/contrib/apache/httpclient/cert/CertificatesManager.java index 00c123f..3f08f4c 100644 --- a/src/main/java/com/wechat/pay/contrib/apache/httpclient/cert/CertificatesManager.java +++ b/src/main/java/com/wechat/pay/contrib/apache/httpclient/cert/CertificatesManager.java @@ -5,6 +5,7 @@ import static org.apache.http.entity.ContentType.APPLICATION_JSON; 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.auth.Verifier; import com.wechat.pay.contrib.apache.httpclient.auth.WechatPay2Validator; @@ -17,6 +18,7 @@ import java.security.GeneralSecurityException; 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; @@ -68,6 +70,19 @@ public class CertificatesManager { */ private ScheduledExecutorService executor; + private static final Validator emptyValidator = + new Validator() { + @Override + public boolean validate(CloseableHttpResponse response) throws IOException { + return true; + }; + + @Override + public String getSerialNumber() { + return ""; + } + }; + private CertificatesManager() { } @@ -235,8 +250,7 @@ private synchronized void downloadAndUpdateCert(String merchantId, Verifier veri proxy = resolveProxy(); try (CloseableHttpClient httpClient = WechatPayHttpClientBuilder.create() .withCredentials(credentials) - .withValidator(verifier == null ? (response) -> true - : new WechatPay2Validator(verifier)) + .withValidator(verifier == null ? emptyValidator : new WechatPay2Validator(verifier)) .withProxy(proxy) .build()) { HttpGet httpGet = new HttpGet(CERT_DOWNLOAD_PATH); @@ -297,7 +311,6 @@ private void updateCertificates() { * 内部验签器 */ private class DefaultVerifier implements Verifier { - private String merchantId; private DefaultVerifier(String merchantId) { @@ -330,7 +343,6 @@ public boolean verify(String serialNumber, byte[] message, String signature) { } } - @Override public X509Certificate getValidCertificate() { X509Certificate certificate; try { @@ -340,5 +352,15 @@ public X509Certificate getValidCertificate() { } return certificate; } + + @Override + public PublicKey getValidPublicKey() { + return getValidCertificate().getPublicKey(); + } + + @Override + public String getSerialNumber() { + return getValidCertificate().getSerialNumber().toString(16).toUpperCase(); + } } } diff --git a/src/main/java/com/wechat/pay/contrib/apache/httpclient/util/PemUtil.java b/src/main/java/com/wechat/pay/contrib/apache/httpclient/util/PemUtil.java index 4921875..1174d93 100644 --- a/src/main/java/com/wechat/pay/contrib/apache/httpclient/util/PemUtil.java +++ b/src/main/java/com/wechat/pay/contrib/apache/httpclient/util/PemUtil.java @@ -6,6 +6,7 @@ import java.security.KeyFactory; import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; +import java.security.PublicKey; import java.security.cert.CertificateException; import java.security.cert.CertificateExpiredException; import java.security.cert.CertificateFactory; @@ -13,6 +14,7 @@ import java.security.cert.X509Certificate; import java.security.spec.InvalidKeySpecException; import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; import java.util.Base64; /** @@ -52,6 +54,37 @@ public static PrivateKey loadPrivateKey(InputStream inputStream) { return loadPrivateKey(privateKey); } + public static PublicKey loadPublicKey(String publicKey) { + String keyString = publicKey + .replace("-----BEGIN PUBLIC KEY-----", "") + .replace("-----END PUBLIC KEY-----", "") + .replaceAll("\\s+", ""); + + try { + return KeyFactory.getInstance("RSA") + .generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(keyString))); + } catch (NoSuchAlgorithmException e) { + throw new UnsupportedOperationException(e); + } catch (InvalidKeySpecException e) { + throw new IllegalArgumentException(e); + } + } + + public static PublicKey loadPublicKey(InputStream inputStream) { + ByteArrayOutputStream os = new ByteArrayOutputStream(2048); + byte[] buffer = new byte[1024]; + String publicKey; + try { + for (int length; (length = inputStream.read(buffer)) != -1; ) { + os.write(buffer, 0, length); + } + publicKey = os.toString("UTF-8"); + } catch (IOException e) { + throw new IllegalArgumentException("无效的公钥", e); + } + return loadPublicKey(publicKey); + } + public static X509Certificate loadCertificate(InputStream inputStream) { try { CertificateFactory cf = CertificateFactory.getInstance("X509"); diff --git a/src/main/java/com/wechat/pay/contrib/apache/httpclient/util/RsaCryptoUtil.java b/src/main/java/com/wechat/pay/contrib/apache/httpclient/util/RsaCryptoUtil.java index 19680ed..fbed9b8 100644 --- a/src/main/java/com/wechat/pay/contrib/apache/httpclient/util/RsaCryptoUtil.java +++ b/src/main/java/com/wechat/pay/contrib/apache/httpclient/util/RsaCryptoUtil.java @@ -4,6 +4,7 @@ import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; +import java.security.PublicKey; import java.security.cert.X509Certificate; import java.util.Base64; import javax.crypto.BadPaddingException; @@ -23,9 +24,17 @@ public static String encryptOAEP(String message, X509Certificate certificate) th } public static String encrypt(String message, X509Certificate certificate, String transformation) throws IllegalBlockSizeException { + return encrypt(message, certificate.getPublicKey(), transformation); + } + + public static String encryptOAEP(String message, PublicKey publicKey) throws IllegalBlockSizeException { + return encrypt(message, publicKey, TRANSFORMATION); + } + + public static String encrypt(String message, PublicKey publicKey, String transformation) throws IllegalBlockSizeException { try { Cipher cipher = Cipher.getInstance(transformation); - cipher.init(Cipher.ENCRYPT_MODE, certificate.getPublicKey()); + cipher.init(Cipher.ENCRYPT_MODE, publicKey); byte[] data = message.getBytes(StandardCharsets.UTF_8); byte[] ciphertext = cipher.doFinal(data); return Base64.getEncoder().encodeToString(ciphertext); diff --git a/src/test/java/com/wechat/pay/contrib/apache/httpclient/HttpClientBuilderTest.java b/src/test/java/com/wechat/pay/contrib/apache/httpclient/HttpClientBuilderTest.java index 4de249e..e0cb0d4 100644 --- a/src/test/java/com/wechat/pay/contrib/apache/httpclient/HttpClientBuilderTest.java +++ b/src/test/java/com/wechat/pay/contrib/apache/httpclient/HttpClientBuilderTest.java @@ -13,6 +13,7 @@ import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.security.PrivateKey; +import java.security.PublicKey; import java.security.cert.X509Certificate; import java.util.ArrayList; import java.util.function.Consumer; @@ -43,9 +44,11 @@ public class HttpClientBuilderTest { // 你的商户私钥 private static final String privateKey = "-----BEGIN PRIVATE KEY-----\n" + "-----END PRIVATE KEY-----"; - // 你的微信支付平台证书 - private static final String certificate = "-----BEGIN CERTIFICATE-----\n" - + "-----END CERTIFICATE-----"; + // 微信支付公钥 + private static final String wechatPayPublicKeyStr = "-----BEGIN PUBLIC KEY-----\n" + + "-----END PUBLIC KEY-----"; + // 微信支付公钥ID + private static final String wechatpayPublicKeyId = "PUB_KEY_ID_"; private CloseableHttpClient httpClient; private static final HttpHost proxy = null; @@ -53,15 +56,11 @@ public class HttpClientBuilderTest { @Before public void setup() { PrivateKey merchantPrivateKey = PemUtil.loadPrivateKey(privateKey); - X509Certificate wechatPayCert = PemUtil.loadCertificate( - new ByteArrayInputStream(certificate.getBytes(StandardCharsets.UTF_8))); - - ArrayList wechatPayCertificates = new ArrayList<>(); - wechatPayCertificates.add(wechatPayCert); + PublicKey wechatPayPublicKey = PemUtil.loadPublicKey(wechatPayPublicKeyStr); httpClient = WechatPayHttpClientBuilder.create() .withMerchant(merchantId, merchantSerialNumber, merchantPrivateKey) - .withWechatPay(wechatPayCertificates) + .withWechatPay(wechatpayPublicKeyId, wechatPayPublicKey) .withProxy(proxy) .build(); } @@ -83,18 +82,6 @@ public void getCertificateTest() throws Exception { } - @Test - public void getCertificatesWithoutCertTest() throws Exception { - PrivateKey merchantPrivateKey = PemUtil.loadPrivateKey(privateKey); - - httpClient = WechatPayHttpClientBuilder.create() - .withMerchant(merchantId, merchantSerialNumber, merchantPrivateKey) - .withValidator(response -> true) - .build(); - - getCertificateTest(); - } - @Test public void postNonRepeatableEntityTest() throws IOException { HttpPost httpPost = new HttpPost( diff --git a/src/test/java/com/wechat/pay/contrib/apache/httpclient/NotificationHandlerTest.java b/src/test/java/com/wechat/pay/contrib/apache/httpclient/NotificationHandlerTest.java index ce3cad0..5905036 100644 --- a/src/test/java/com/wechat/pay/contrib/apache/httpclient/NotificationHandlerTest.java +++ b/src/test/java/com/wechat/pay/contrib/apache/httpclient/NotificationHandlerTest.java @@ -1,6 +1,8 @@ package com.wechat.pay.contrib.apache.httpclient; +import com.wechat.pay.contrib.apache.httpclient.auth.MixVerifier; 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.Verifier; import com.wechat.pay.contrib.apache.httpclient.auth.WechatPay2Credentials; import com.wechat.pay.contrib.apache.httpclient.cert.CertificatesManager; @@ -10,6 +12,8 @@ import com.wechat.pay.contrib.apache.httpclient.util.PemUtil; import java.nio.charset.StandardCharsets; import java.security.PrivateKey; +import java.security.PublicKey; + import org.junit.Assert; import org.junit.Before; import org.junit.Test; @@ -22,11 +26,15 @@ public class NotificationHandlerTest { private static final String merchantSerialNumber = ""; // 商户证书序列号 private static final String apiV3Key = ""; // apiV3密钥 private static final String wechatPaySerial = ""; // 平台证书序列号 + private static final String wechatPayPublicKeyStr = "-----BEGIN PUBLIC KEY-----\n" + + "-----END PUBLIC KEY-----"; // 微信支付公钥 + private static final String wechatpayPublicKeyId = "PUB_KEY_ID_"; // 微信支付公钥ID private static final String nonce = ""; // 请求头Wechatpay-Nonce private static final String timestamp = "";// 请求头Wechatpay-Timestamp private static final String signature = "";// 请求头Wechatpay-Signature private static final String body = ""; // 请求体 - private Verifier verifier; // 验签器 + private Verifier publicKeyVerifier; // 微信支付公钥验签器 + private Verifier certificateVerifier; // 平台证书验签器 private static CertificatesManager certificatesManager; // 平台证书管理器 @Before @@ -39,19 +47,55 @@ public void setup() throws Exception { new PrivateKeySigner(merchantSerialNumber, merchantPrivateKey)), apiV3Key.getBytes(StandardCharsets.UTF_8)); // 从证书管理器中获取verifier - verifier = certificatesManager.getVerifier(merchantId); + certificateVerifier = certificatesManager.getVerifier(merchantId); + // 创建公钥验签器 + PublicKey wechatPayPublicKey = PemUtil.loadPublicKey(wechatPayPublicKeyStr); + publicKeyVerifier = new PublicKeyVerifier(wechatpayPublicKeyId, wechatPayPublicKey); } - @Test - public void notificationHandlerTest() throws Exception { - // 构建request,传入必要参数 - NotificationRequest request = new NotificationRequest.Builder().withSerialNumber(wechatPaySerial) + private NotificationRequest buildNotificationRequest() { + return new NotificationRequest.Builder().withSerialNumber(wechatPaySerial) .withNonce(nonce) .withTimestamp(timestamp) .withSignature(signature) .withBody(body) .build(); - NotificationHandler handler = new NotificationHandler(verifier, apiV3Key.getBytes(StandardCharsets.UTF_8)); + } + + @Test + public void handleNotificationWithPublicKeyVerifier() throws Exception { + NotificationRequest request = buildNotificationRequest(); + + // 使用微信支付公钥验签器:适用于已经完成「平台证书」-->「微信支付公钥」迁移的商户以及新申请的商户 + NotificationHandler handler = new NotificationHandler(certificateVerifier, apiV3Key.getBytes(StandardCharsets.UTF_8)); + + // 验签和解析请求体 + Notification notification = handler.parse(request); + Assert.assertNotNull(notification); + System.out.println(notification.toString()); + } + + @Test + public void handleNotificationWithMixVerifier() throws Exception { + NotificationRequest request = buildNotificationRequest(); + + // 使用混合验签器:适用于正在进行「平台证书」-->「微信支付公钥」迁移的商户 + Verifier mixVerifier = new MixVerifier((PublicKeyVerifier) publicKeyVerifier, certificateVerifier); + NotificationHandler handler = new NotificationHandler(mixVerifier, apiV3Key.getBytes(StandardCharsets.UTF_8)); + + // 验签和解析请求体 + Notification notification = handler.parse(request); + Assert.assertNotNull(notification); + System.out.println(notification.toString()); + } + + @Test + public void handleNotificationWithCertificateVerifier() throws Exception { + NotificationRequest request = buildNotificationRequest(); + + // 使用平台证书验签器:适用于尚未开始「平台证书」-->「微信支付公钥」迁移的旧商户 + NotificationHandler handler = new NotificationHandler(certificateVerifier, apiV3Key.getBytes(StandardCharsets.UTF_8)); + // 验签和解析请求体 Notification notification = handler.parse(request); Assert.assertNotNull(notification); diff --git a/src/test/java/com/wechat/pay/contrib/apache/httpclient/RsaCryptoTest.java b/src/test/java/com/wechat/pay/contrib/apache/httpclient/RsaCryptoTest.java index 623ff24..15e5aa7 100644 --- a/src/test/java/com/wechat/pay/contrib/apache/httpclient/RsaCryptoTest.java +++ b/src/test/java/com/wechat/pay/contrib/apache/httpclient/RsaCryptoTest.java @@ -116,8 +116,7 @@ public void after() throws IOException { @Test public void encryptTest() throws Exception { String text = "helloworld"; - String ciphertext = RsaCryptoUtil - .encryptOAEP(text, verifier.getValidCertificate()); + String ciphertext = RsaCryptoUtil.encryptOAEP(text, verifier.getValidPublicKey()); System.out.println("ciphertext: " + ciphertext); } @@ -170,8 +169,7 @@ public void postEncryptDataTest() throws Exception { HttpPost httpPost = new HttpPost("https://api.mch.weixin.qq.com/v3/smartguide/guides"); String text = "helloworld"; - String ciphertext = RsaCryptoUtil - .encryptOAEP(text, verifier.getValidCertificate()); + String ciphertext = RsaCryptoUtil.encryptOAEP(text, verifier.getValidPublicKey()); String data = "{\n" + " \"store_id\" : 1234,\n"