diff --git a/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/api/impl/WxMaInternetServiceImpl.java b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/api/impl/WxMaInternetServiceImpl.java index 7da44ddaba..91d11795f3 100644 --- a/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/api/impl/WxMaInternetServiceImpl.java +++ b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/api/impl/WxMaInternetServiceImpl.java @@ -9,6 +9,7 @@ import me.chanjar.weixin.common.enums.WxType; import me.chanjar.weixin.common.error.WxError; import me.chanjar.weixin.common.error.WxErrorException; +import org.apache.commons.codec.binary.Base64; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; @@ -26,7 +27,7 @@ public class WxMaInternetServiceImpl implements WxMaInternetService { private String sha256(String data, String sessionKey) throws Exception { Mac sha256_HMAC = Mac.getInstance("HmacSHA256"); - SecretKeySpec secret_key = new SecretKeySpec(sessionKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + SecretKeySpec secret_key = new SecretKeySpec(Base64.decodeBase64(sessionKey), "HmacSHA256"); sha256_HMAC.init(secret_key); byte[] array = sha256_HMAC.doFinal(data.getBytes(StandardCharsets.UTF_8)); StringBuilder sb = new StringBuilder(); @@ -57,7 +58,7 @@ public WxMaInternetResponse getUserEncryptKey(String openid, String sessionKey) private WxMaInternetResponse getWxMaInternetResponse(String url) throws WxErrorException { String responseContent = this.wxMaService.post(url, ""); WxMaInternetResponse response = WxMaGsonBuilder.create().fromJson(responseContent, WxMaInternetResponse.class); - if (response.getErrcode() == -1) { + if (response.getErrcode() != null && response.getErrcode() != 0) { throw new WxErrorException(WxError.fromJson(responseContent, WxType.MiniApp)); } return response; diff --git a/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/bean/internet/WxMaInternetUserKeyInfo.java b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/bean/internet/WxMaInternetUserKeyInfo.java index 01bcfbce0b..305d8687e0 100644 --- a/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/bean/internet/WxMaInternetUserKeyInfo.java +++ b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/bean/internet/WxMaInternetUserKeyInfo.java @@ -44,7 +44,7 @@ public class WxMaInternetUserKeyInfo implements Serializable { private Long expireIn; /** - * 加密iv + * 加密iv(Hex 编码,通常为 32 位十六进制字符,解码后为 16 字节,用于 AES-128-CBC) */ private String iv; diff --git a/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/util/crypt/WxMaCryptUtils.java b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/util/crypt/WxMaCryptUtils.java index 2343634bfc..252297fdcd 100644 --- a/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/util/crypt/WxMaCryptUtils.java +++ b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/util/crypt/WxMaCryptUtils.java @@ -84,4 +84,103 @@ public static String decryptAnotherWay(String sessionKey, String encryptedData, } } + /** + * 使用用户加密 key 对数据进行 AES-128-CBC 解密(用于小程序加密网络通道). + * + *
+ * 参考文档:https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/user-encryptkey.html + * encryptKey 来自 getUserEncryptKey 接口返回的 encrypt_key 字段(Base64 编码,解码后须为 16 字节) + * hexIv 来自 getUserEncryptKey 接口返回的 iv 字段(Hex 编码,须为 32 位十六进制字符,解码后为 16 字节) + *+ * + * @param encryptKey 用户加密 key(Base64 编码,解码后须为 16 字节) + * @param hexIv 加密 iv(Hex 编码,须为 32 位十六进制字符) + * @param encryptedData 加密数据(Base64 编码) + * @return 解密后的字符串 + * @throws IllegalArgumentException 如果 encryptKey 解码后不为 16 字节,或 hexIv 格式非法/解码后不为 16 字节 + */ + public static String decryptWithEncryptKey(String encryptKey, String hexIv, String encryptedData) { + byte[] keyBytes = Base64.decodeBase64(encryptKey); + if (keyBytes.length != 16) { + throw new IllegalArgumentException( + "encryptKey 解码后必须为 16 字节(AES-128),实际为 " + keyBytes.length + " 字节"); + } + byte[] ivBytes = hexToBytes(hexIv); + if (ivBytes.length != 16) { + throw new IllegalArgumentException( + "hexIv 解码后必须为 16 字节(AES-128-CBC),实际为 " + ivBytes.length + " 字节(需 32 位 Hex 字符串)"); + } + byte[] dataBytes = Base64.decodeBase64(encryptedData); + try { + Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + cipher.init(Cipher.DECRYPT_MODE, + new SecretKeySpec(keyBytes, "AES"), + new IvParameterSpec(ivBytes)); + return new String(cipher.doFinal(dataBytes), UTF_8); + } catch (Exception e) { + throw new WxRuntimeException("AES解密失败!", e); + } + } + + /** + * 使用用户加密 key 对数据进行 AES-128-CBC 加密(用于小程序加密网络通道). + * + *
+ * 参考文档:https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/user-encryptkey.html + * encryptKey 来自 getUserEncryptKey 接口返回的 encrypt_key 字段(Base64 编码,解码后须为 16 字节) + * hexIv 来自 getUserEncryptKey 接口返回的 iv 字段(Hex 编码,须为 32 位十六进制字符,解码后为 16 字节) + *+ * + * @param encryptKey 用户加密 key(Base64 编码,解码后须为 16 字节) + * @param hexIv 加密 iv(Hex 编码,须为 32 位十六进制字符) + * @param data 待加密的明文字符串 + * @return 加密后的数据(Base64 编码) + * @throws IllegalArgumentException 如果 encryptKey 解码后不为 16 字节,或 hexIv 格式非法/解码后不为 16 字节 + */ + public static String encryptWithEncryptKey(String encryptKey, String hexIv, String data) { + byte[] keyBytes = Base64.decodeBase64(encryptKey); + if (keyBytes.length != 16) { + throw new IllegalArgumentException( + "encryptKey 解码后必须为 16 字节(AES-128),实际为 " + keyBytes.length + " 字节"); + } + byte[] ivBytes = hexToBytes(hexIv); + if (ivBytes.length != 16) { + throw new IllegalArgumentException( + "hexIv 解码后必须为 16 字节(AES-128-CBC),实际为 " + ivBytes.length + " 字节(需 32 位 Hex 字符串)"); + } + try { + Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + cipher.init(Cipher.ENCRYPT_MODE, + new SecretKeySpec(keyBytes, "AES"), + new IvParameterSpec(ivBytes)); + return Base64.encodeBase64String(cipher.doFinal(data.getBytes(UTF_8))); + } catch (Exception e) { + throw new WxRuntimeException("AES加密失败!", e); + } + } + + /** + * 将 Hex 字符串转换为字节数组. + * + * @param hex Hex 字符串(长度必须为偶数,只包含 0-9 和 a-f/A-F 字符) + * @return 字节数组 + * @throws IllegalArgumentException 如果输入不是合法的 Hex 字符串 + */ + private static byte[] hexToBytes(String hex) { + if (hex == null || hex.length() % 2 != 0) { + throw new IllegalArgumentException("无效的十六进制字符串格式:长度必须为偶数"); + } + int len = hex.length(); + byte[] data = new byte[len / 2]; + for (int i = 0; i < len; i += 2) { + int high = Character.digit(hex.charAt(i), 16); + int low = Character.digit(hex.charAt(i + 1), 16); + if (high == -1 || low == -1) { + throw new IllegalArgumentException("无效的十六进制字符串格式:包含非法字符 '" + hex.charAt(high == -1 ? i : i + 1) + "'"); + } + data[i / 2] = (byte) ((high << 4) + low); + } + return data; + } + } diff --git a/weixin-java-miniapp/src/test/java/cn/binarywang/wx/miniapp/util/crypt/WxMaCryptUtilsTest.java b/weixin-java-miniapp/src/test/java/cn/binarywang/wx/miniapp/util/crypt/WxMaCryptUtilsTest.java index 76b4e96743..742fa7d440 100644 --- a/weixin-java-miniapp/src/test/java/cn/binarywang/wx/miniapp/util/crypt/WxMaCryptUtilsTest.java +++ b/weixin-java-miniapp/src/test/java/cn/binarywang/wx/miniapp/util/crypt/WxMaCryptUtilsTest.java @@ -4,6 +4,7 @@ import org.testng.annotations.*; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; /** *
@@ -14,6 +15,11 @@
* @author Binary Wang
*/
public class WxMaCryptUtilsTest {
+ // 模拟来自 getUserEncryptKey 接口返回的 encrypt_key(Base64,解码后 16 字节)
+ // 和 iv(Hex,32 位十六进制字符,解码后 16 字节,AES-128-CBC 要求)
+ private static final String ENCRYPT_KEY = "VI6BpyrK9XH4i4AIGe86tg==";
+ private static final String HEX_IV = "6003f73ec441c3866003f73ec441c386";
+
@Test
public void testDecrypt() {
String sessionKey = "7MG7jbTToVVRWRXVA885rg==";
@@ -32,4 +38,98 @@ public void testDecryptAnotherWay() {
assertThat(WxMaCryptUtils.decrypt(sessionKey, encryptedData, ivStr))
.isEqualTo(WxMaCryptUtils.decryptAnotherWay(sessionKey, encryptedData, ivStr));
}
+
+ /**
+ * 测试使用用户加密 key(来自小程序加密网络通道)进行加密和解密的对称性.
+ * encrypt_key 为 Base64 编码的 16 字节 AES-128 密钥,iv 为 Hex 编码的 16 字节初始向量。
+ */
+ @Test
+ public void testEncryptAndDecryptWithEncryptKey() {
+ String plainText = "{\"userId\":\"12345\",\"amount\":100}";
+
+ String encrypted = WxMaCryptUtils.encryptWithEncryptKey(ENCRYPT_KEY, HEX_IV, plainText);
+ assertThat(encrypted).isNotNull().isNotEmpty();
+
+ String decrypted = WxMaCryptUtils.decryptWithEncryptKey(ENCRYPT_KEY, HEX_IV, encrypted);
+ assertThat(decrypted).isEqualTo(plainText);
+ }
+
+ /**
+ * 测试加密网络通道的加解密对称性(不同明文).
+ */
+ @Test
+ public void testEncryptDecryptSymmetryWithEncryptKey() {
+ String plainText = "hello miniprogram";
+
+ String encrypted = WxMaCryptUtils.encryptWithEncryptKey(ENCRYPT_KEY, HEX_IV, plainText);
+ String decrypted = WxMaCryptUtils.decryptWithEncryptKey(ENCRYPT_KEY, HEX_IV, encrypted);
+ assertThat(decrypted).isEqualTo(plainText);
+ }
+
+ /**
+ * 测试 hexIv 为奇数长度时,应抛出 IllegalArgumentException.
+ */
+ @Test
+ public void testEncryptWithEncryptKeyInvalidHexIvOddLength() {
+ assertThatThrownBy(() -> WxMaCryptUtils.encryptWithEncryptKey(ENCRYPT_KEY, "abc", "data"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("长度必须为偶数");
+ }
+
+ /**
+ * 测试 hexIv 包含非十六进制字符时,应抛出 IllegalArgumentException.
+ */
+ @Test
+ public void testEncryptWithEncryptKeyInvalidHexIvNonHexChar() {
+ // 32 位但含非法字符 'z'
+ assertThatThrownBy(() -> WxMaCryptUtils.encryptWithEncryptKey(
+ ENCRYPT_KEY, "6003f73ec441c3866003f73ec441z386", "data"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("非法字符");
+ }
+
+ /**
+ * 测试 hexIv 解码后不足 16 字节(如仅 16 位 hex = 8 字节)时,应抛出 IllegalArgumentException.
+ */
+ @Test
+ public void testEncryptWithEncryptKeyShortHexIv() {
+ // 16 位 hex = 8 字节,不满足 AES-CBC 要求的 16 字节
+ assertThatThrownBy(() -> WxMaCryptUtils.encryptWithEncryptKey(
+ ENCRYPT_KEY, "6003f73ec441c386", "data"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("hexIv 解码后必须为 16 字节");
+ }
+
+ /**
+ * 测试 encryptKey 解码后不足 16 字节时,应抛出 IllegalArgumentException.
+ */
+ @Test
+ public void testEncryptWithEncryptKeyShortKey() {
+ // Base64 编码的 8 字节 key(不符合 AES-128 要求)
+ String shortKey = java.util.Base64.getEncoder().encodeToString(new byte[8]);
+ assertThatThrownBy(() -> WxMaCryptUtils.encryptWithEncryptKey(shortKey, HEX_IV, "data"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("encryptKey 解码后必须为 16 字节");
+ }
+
+ /**
+ * 测试 decryptWithEncryptKey 使用非法 hexIv 时,应抛出 IllegalArgumentException.
+ */
+ @Test
+ public void testDecryptWithEncryptKeyInvalidHexIv() {
+ assertThatThrownBy(() -> WxMaCryptUtils.decryptWithEncryptKey(ENCRYPT_KEY, "abc", "data"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("长度必须为偶数");
+ }
+
+ /**
+ * 测试 decryptWithEncryptKey encryptKey 长度不合法时,应抛出 IllegalArgumentException.
+ */
+ @Test
+ public void testDecryptWithEncryptKeyShortKey() {
+ String shortKey = java.util.Base64.getEncoder().encodeToString(new byte[8]);
+ assertThatThrownBy(() -> WxMaCryptUtils.decryptWithEncryptKey(shortKey, HEX_IV, "data"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("encryptKey 解码后必须为 16 字节");
+ }
}