diff --git a/weixin-java-common/pom.xml b/weixin-java-common/pom.xml index 5916ab6e37..bc79369bcf 100644 --- a/weixin-java-common/pom.xml +++ b/weixin-java-common/pom.xml @@ -122,6 +122,12 @@ assertj-guava test + + + org.bouncycastle + bcpkix-jdk18on + test + org.dom4j dom4j diff --git a/weixin-java-common/src/main/java/me/chanjar/weixin/common/util/RandomUtils.java b/weixin-java-common/src/main/java/me/chanjar/weixin/common/util/RandomUtils.java index a9017c0d16..80475c6526 100644 --- a/weixin-java-common/src/main/java/me/chanjar/weixin/common/util/RandomUtils.java +++ b/weixin-java-common/src/main/java/me/chanjar/weixin/common/util/RandomUtils.java @@ -1,16 +1,18 @@ package me.chanjar.weixin.common.util; +import java.security.SecureRandom; + public class RandomUtils { private static final String RANDOM_STR = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - private static volatile java.util.Random random; + private static volatile SecureRandom random; - private static java.util.Random getRandom() { + private static SecureRandom getRandom() { if (random == null) { synchronized (RandomUtils.class) { if (random == null) { - random = new java.util.Random(); + random = new SecureRandom(); } } } @@ -19,7 +21,7 @@ private static java.util.Random getRandom() { public static String getRandomStr() { StringBuilder sb = new StringBuilder(); - java.util.Random r = getRandom(); + SecureRandom r = getRandom(); for (int i = 0; i < 16; i++) { sb.append(RANDOM_STR.charAt(r.nextInt(RANDOM_STR.length()))); } diff --git a/weixin-java-common/src/main/java/me/chanjar/weixin/common/util/crypto/WxCryptUtil.java b/weixin-java-common/src/main/java/me/chanjar/weixin/common/util/crypto/WxCryptUtil.java index 50362636fc..8bff9a2b80 100755 --- a/weixin-java-common/src/main/java/me/chanjar/weixin/common/util/crypto/WxCryptUtil.java +++ b/weixin-java-common/src/main/java/me/chanjar/weixin/common/util/crypto/WxCryptUtil.java @@ -18,8 +18,9 @@ import java.io.StringReader; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; import java.util.Arrays; -import java.util.Random; /** *
@@ -37,13 +38,13 @@ public class WxCryptUtil {
   private static final Base64 BASE64 = new Base64();
   private static final Charset CHARSET = StandardCharsets.UTF_8;
 
-  private static volatile Random random;
+  private static volatile SecureRandom random;
 
-  private static Random getRandom() {
+  private static SecureRandom getRandom() {
     if (random == null) {
       synchronized (WxCryptUtil.class) {
         if (random == null) {
-          random = new Random();
+          random = new SecureRandom();
         }
       }
     }
@@ -122,7 +123,7 @@ private static int bytesNetworkOrder2Number(byte[] bytesInNetworkOrder) {
    */
   private static String genRandomStr() {
     String base = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
-    Random r = getRandom();
+    SecureRandom r = getRandom();
     StringBuilder sb = new StringBuilder();
     for (int i = 0; i < 16; i++) {
       int number = r.nextInt(base.length());
@@ -131,6 +132,20 @@ private static String genRandomStr() {
     return sb.toString();
   }
 
+  /**
+   * 以固定时间的方式比较两个签名,避免时序攻击.
+   *
+   * @param expected 本地计算出的签名
+   * @param actual   请求中携带的签名
+   * @return 两者是否一致
+   */
+  private static boolean isSignatureEqual(String expected, String actual) {
+    if (expected == null || actual == null) {
+      return false;
+    }
+    return MessageDigest.isEqual(expected.getBytes(CHARSET), actual.getBytes(CHARSET));
+  }
+
   /**
    * 生成xml消息.
    *
@@ -296,7 +311,7 @@ public String decrypt(String msgSignature, String timeStamp, String nonce, Strin
   public String decryptContent(String msgSignature, String timeStamp, String nonce, String encryptedContent) {
     // 验证安全签名
     String signature = SHA1.gen(this.token, timeStamp, nonce, encryptedContent);
-    if (!signature.equals(msgSignature)) {
+    if (!isSignatureEqual(signature, msgSignature)) {
       throw new WxRuntimeException("加密消息签名校验失败");
     }
     // 解密
diff --git a/weixin-java-common/src/main/java/me/chanjar/weixin/common/util/http/apache/DefaultApacheHttpClientBuilder.java b/weixin-java-common/src/main/java/me/chanjar/weixin/common/util/http/apache/DefaultApacheHttpClientBuilder.java
index ef7120b768..1ef22e7823 100644
--- a/weixin-java-common/src/main/java/me/chanjar/weixin/common/util/http/apache/DefaultApacheHttpClientBuilder.java
+++ b/weixin-java-common/src/main/java/me/chanjar/weixin/common/util/http/apache/DefaultApacheHttpClientBuilder.java
@@ -99,6 +99,14 @@ public class DefaultApacheHttpClientBuilder implements ApacheHttpClientBuilder {
    */
   private String[] supportedProtocols = {"TLSv1.2", "TLSv1.3", "TLSv1.1", "TLSv1"};
 
+  /**
+   * 是否跳过服务器端证书校验,默认false,即校验证书。
+   * 

+ * 仅在自签名证书的抓包代理等特殊调试场景下才可以设置为true,生产环境开启会导致中间人攻击风险。 + *

+ */ + private boolean skipServerCertificateVerification = false; + /** * 自定义请求拦截器 */ @@ -121,7 +129,13 @@ public class DefaultApacheHttpClientBuilder implements ApacheHttpClientBuilder { private final HttpRequestRetryHandler defaultHttpRequestRetryHandler = (exception, executionCount, context) -> false; - private SSLConnectionSocketFactory sslConnectionSocketFactory = SSLConnectionSocketFactory.getSocketFactory(); + /** + * 默认的SSL连接工厂,用于判断使用方是否自定义过连接工厂 + */ + private static final SSLConnectionSocketFactory DEFAULT_SSL_CONNECTION_SOCKET_FACTORY = + SSLConnectionSocketFactory.getSocketFactory(); + + private SSLConnectionSocketFactory sslConnectionSocketFactory = DEFAULT_SSL_CONNECTION_SOCKET_FACTORY; private final PlainConnectionSocketFactory plainConnectionSocketFactory = PlainConnectionSocketFactory.getSocketFactory(); private String httpProxyHost; private int httpProxyPort; @@ -199,9 +213,10 @@ private synchronized void prepare() { if (prepared.get()) { return; } + SSLConnectionSocketFactory httpsSocketFactory = this.resolveSSLConnectionSocketFactory(); Registry registry = RegistryBuilder.create() .register("http", this.plainConnectionSocketFactory) - .register("https", this.sslConnectionSocketFactory) + .register("https", httpsSocketFactory) .build(); PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager(registry); @@ -221,7 +236,7 @@ private synchronized void prepare() { HttpClientBuilder httpClientBuilder = HttpClients.custom() .setConnectionManager(connectionManager) .setConnectionManagerShared(true) - .setSSLSocketFactory(this.buildSSLConnectionSocketFactory()) + .setSSLSocketFactory(httpsSocketFactory) .setDefaultRequestConfig(RequestConfig.custom() .setSocketTimeout(this.soTimeout) .setConnectTimeout(this.connectionTimeout) @@ -261,11 +276,27 @@ private synchronized void prepare() { prepared.set(true); } + /** + * 使用方自定义的连接工厂优先,否则按照证书校验开关构造连接工厂. + */ + private SSLConnectionSocketFactory resolveSSLConnectionSocketFactory() { + if (this.sslConnectionSocketFactory != DEFAULT_SSL_CONNECTION_SOCKET_FACTORY) { + return this.sslConnectionSocketFactory; + } + + SSLConnectionSocketFactory factory = this.buildSSLConnectionSocketFactory(); + return factory == null ? DEFAULT_SSL_CONNECTION_SOCKET_FACTORY : factory; + } + private SSLConnectionSocketFactory buildSSLConnectionSocketFactory() { try { - SSLContext sslcontext = SSLContexts.custom() + SSLContext sslcontext; + if (this.skipServerCertificateVerification) { //忽略掉对服务器端证书的校验 - .loadTrustMaterial((TrustStrategy) (chain, authType) -> true).build(); + sslcontext = SSLContexts.custom().loadTrustMaterial((TrustStrategy) (chain, authType) -> true).build(); + } else { + sslcontext = SSLContexts.createSystemDefault(); + } return new SSLConnectionSocketFactory( sslcontext, diff --git a/weixin-java-common/src/main/java/me/chanjar/weixin/common/util/http/hc/DefaultHttpComponentsClientBuilder.java b/weixin-java-common/src/main/java/me/chanjar/weixin/common/util/http/hc/DefaultHttpComponentsClientBuilder.java index 4915e31a16..2238ac7d1f 100644 --- a/weixin-java-common/src/main/java/me/chanjar/weixin/common/util/http/hc/DefaultHttpComponentsClientBuilder.java +++ b/weixin-java-common/src/main/java/me/chanjar/weixin/common/util/http/hc/DefaultHttpComponentsClientBuilder.java @@ -19,6 +19,7 @@ import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; import org.apache.hc.client5.http.ssl.DefaultClientTlsStrategy; +import org.apache.hc.client5.http.ssl.DefaultHostnameVerifier; import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; import org.apache.hc.client5.http.ssl.TrustAllStrategy; import org.apache.hc.core5.http.HttpHost; @@ -94,6 +95,14 @@ public class DefaultHttpComponentsClientBuilder implements HttpComponentsClientB */ private String userAgent; + /** + * 是否跳过服务器端证书校验,默认false,即校验证书。 + *

+ * 仅在自签名证书的抓包代理等特殊调试场景下才可以设置为true,生产环境开启会导致中间人攻击风险。 + *

+ */ + private boolean skipServerCertificateVerification = false; + /** * 自定义请求拦截器 */ @@ -173,16 +182,21 @@ private synchronized void prepare() { SSLContext sslcontext; try { - sslcontext = SSLContexts.custom() - .loadTrustMaterial(TrustAllStrategy.INSTANCE) // 忽略对服务器端证书的校验 - .build(); + if (this.skipServerCertificateVerification) { + sslcontext = SSLContexts.custom() + .loadTrustMaterial(TrustAllStrategy.INSTANCE) // 忽略对服务器端证书的校验 + .build(); + } else { + sslcontext = SSLContexts.createSystemDefault(); + } } catch (NoSuchAlgorithmException | KeyManagementException | KeyStoreException e) { log.error("构建 SSLContext 时发生异常!", e); throw new RuntimeException(e); } PoolingHttpClientConnectionManager connManager = PoolingHttpClientConnectionManagerBuilder.create() - .setTlsSocketStrategy(new DefaultClientTlsStrategy(sslcontext, NoopHostnameVerifier.INSTANCE)) + .setTlsSocketStrategy(new DefaultClientTlsStrategy(sslcontext, + this.skipServerCertificateVerification ? NoopHostnameVerifier.INSTANCE : new DefaultHostnameVerifier())) .setMaxConnTotal(this.maxTotalConn) .setMaxConnPerRoute(this.maxConnPerHost) .setDefaultSocketConfig(SocketConfig.custom() diff --git a/weixin-java-common/src/test/java/me/chanjar/weixin/common/util/http/ServerCertificateVerificationTest.java b/weixin-java-common/src/test/java/me/chanjar/weixin/common/util/http/ServerCertificateVerificationTest.java new file mode 100644 index 0000000000..fa161b5071 --- /dev/null +++ b/weixin-java-common/src/test/java/me/chanjar/weixin/common/util/http/ServerCertificateVerificationTest.java @@ -0,0 +1,154 @@ +package me.chanjar.weixin.common.util.http; + +import com.sun.net.httpserver.HttpsConfigurator; +import com.sun.net.httpserver.HttpsServer; +import me.chanjar.weixin.common.util.http.apache.DefaultApacheHttpClientBuilder; +import me.chanjar.weixin.common.util.http.hc.DefaultHttpComponentsClientBuilder; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.impl.client.CloseableHttpClient; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.testng.Assert; +import org.testng.annotations.AfterClass; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.Test; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLException; +import java.io.OutputStream; +import java.lang.reflect.Constructor; +import java.math.BigInteger; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.Date; + +/** + * 验证默认情况下会校验服务器端证书(避免中间人攻击),且显式跳过校验的开关确实生效. + */ +public class ServerCertificateVerificationTest { + + private static final char[] KEY_STORE_PASSWORD = "wxjava".toCharArray(); + + private HttpsServer httpsServer; + private String selfSignedUrl; + + @BeforeClass + public void startSelfSignedHttpsServer() throws Exception { + KeyStore keyStore = createSelfSignedKeyStore(); + KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + keyManagerFactory.init(keyStore, KEY_STORE_PASSWORD); + + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(keyManagerFactory.getKeyManagers(), null, null); + + this.httpsServer = HttpsServer.create(new InetSocketAddress(0), 0); + this.httpsServer.setHttpsConfigurator(new HttpsConfigurator(sslContext)); + this.httpsServer.createContext("/", exchange -> { + byte[] body = "ok".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(body); + } + }); + this.httpsServer.start(); + this.selfSignedUrl = "https://localhost:" + this.httpsServer.getAddress().getPort() + "/"; + } + + @AfterClass(alwaysRun = true) + public void stopSelfSignedHttpsServer() { + if (this.httpsServer != null) { + this.httpsServer.stop(0); + } + } + + @Test + public void testApacheBuilderRejectsUntrustedCertificateByDefault() throws Exception { + DefaultApacheHttpClientBuilder builder = newApacheBuilder(); + Assert.assertFalse(builder.isSkipServerCertificateVerification(), "默认应校验服务器端证书"); + + try (CloseableHttpClient client = builder.build()) { + client.execute(new HttpGet(this.selfSignedUrl)); + Assert.fail("默认配置下应拒绝自签名证书"); + } catch (SSLException e) { + // 期望的结果:证书校验失败 + } + } + + @Test + public void testApacheBuilderAcceptsUntrustedCertificateWhenSkipEnabled() throws Exception { + DefaultApacheHttpClientBuilder builder = newApacheBuilder(); + builder.setSkipServerCertificateVerification(true); + + try (CloseableHttpClient client = builder.build(); + CloseableHttpResponse response = client.execute(new HttpGet(this.selfSignedUrl))) { + Assert.assertEquals(response.getStatusLine().getStatusCode(), 200); + } + } + + @Test + public void testHttpComponentsBuilderRejectsUntrustedCertificateByDefault() throws Exception { + DefaultHttpComponentsClientBuilder builder = newHttpComponentsBuilder(); + Assert.assertFalse(builder.isSkipServerCertificateVerification(), "默认应校验服务器端证书"); + + try (org.apache.hc.client5.http.impl.classic.CloseableHttpClient client = builder.build()) { + client.execute(new org.apache.hc.client5.http.classic.methods.HttpGet(this.selfSignedUrl)); + Assert.fail("默认配置下应拒绝自签名证书"); + } catch (SSLException e) { + // 期望的结果:证书校验失败 + } + } + + @Test + public void testHttpComponentsBuilderAcceptsUntrustedCertificateWhenSkipEnabled() throws Exception { + DefaultHttpComponentsClientBuilder builder = newHttpComponentsBuilder(); + builder.setSkipServerCertificateVerification(true); + + try (org.apache.hc.client5.http.impl.classic.CloseableHttpClient client = builder.build(); + org.apache.hc.client5.http.impl.classic.CloseableHttpResponse response = + client.execute(new org.apache.hc.client5.http.classic.methods.HttpGet(this.selfSignedUrl))) { + Assert.assertEquals(response.getCode(), 200); + } + } + + private DefaultApacheHttpClientBuilder newApacheBuilder() throws Exception { + Constructor constructor = + DefaultApacheHttpClientBuilder.class.getDeclaredConstructor(); + constructor.setAccessible(true); + return constructor.newInstance(); + } + + private DefaultHttpComponentsClientBuilder newHttpComponentsBuilder() throws Exception { + Constructor constructor = + DefaultHttpComponentsClientBuilder.class.getDeclaredConstructor(); + constructor.setAccessible(true); + return constructor.newInstance(); + } + + private KeyStore createSelfSignedKeyStore() throws Exception { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + KeyPair keyPair = keyPairGenerator.generateKeyPair(); + + long now = System.currentTimeMillis(); + X500Name subject = new X500Name("CN=localhost"); + X509Certificate certificate = new JcaX509CertificateConverter().getCertificate( + new JcaX509v3CertificateBuilder(subject, BigInteger.valueOf(now), + new Date(now - 86400_000L), new Date(now + 86400_000L), subject, keyPair.getPublic()) + .build(new JcaContentSignerBuilder("SHA256withRSA").build(keyPair.getPrivate()))); + + KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + keyStore.load(null, null); + keyStore.setKeyEntry("wxjava-test", keyPair.getPrivate(), KEY_STORE_PASSWORD, + new Certificate[]{certificate}); + return keyStore; + } +} diff --git a/weixin-java-common/src/test/resources/testng.xml b/weixin-java-common/src/test/resources/testng.xml index 9eeba0df4c..5f53aa6352 100644 --- a/weixin-java-common/src/test/resources/testng.xml +++ b/weixin-java-common/src/test/resources/testng.xml @@ -4,11 +4,11 @@ - + diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/util/SignUtils.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/util/SignUtils.java index 37a1ef2efd..5a1b6152d6 100644 --- a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/util/SignUtils.java +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/util/SignUtils.java @@ -4,7 +4,6 @@ import java.security.*; import java.util.Base64; -import java.util.Random; /** * @author cloudx @@ -33,13 +32,13 @@ public static String genRandomStr() { return genRandomStr(32); } - private static volatile Random random; + private static volatile SecureRandom random; - private static Random getRandom() { + private static SecureRandom getRandom() { if (random == null) { synchronized (SignUtils.class) { if (random == null) { - random = new Random(); + random = new SecureRandom(); } } } @@ -54,7 +53,7 @@ private static Random getRandom() { */ public static String genRandomStr(int length) { String base = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - Random r = getRandom(); + SecureRandom r = getRandom(); StringBuilder sb = new StringBuilder(); for (int i = 0; i < length; i++) { int number = r.nextInt(base.length());