1
0
mirror of synced 2026-05-20 09:16:25 +08:00

🆕 #3969 【小程序】实现加密网络通道服务端支持,并修复 HMAC 签名与错误处理 Bug

This commit is contained in:
Copilot
2026-05-11 20:29:31 +08:00
committed by GitHub
parent b545874f1a
commit bcb3110bd7
4 changed files with 203 additions and 3 deletions

View File

@@ -9,6 +9,7 @@ import lombok.RequiredArgsConstructor;
import me.chanjar.weixin.common.enums.WxType; import me.chanjar.weixin.common.enums.WxType;
import me.chanjar.weixin.common.error.WxError; import me.chanjar.weixin.common.error.WxError;
import me.chanjar.weixin.common.error.WxErrorException; import me.chanjar.weixin.common.error.WxErrorException;
import org.apache.commons.codec.binary.Base64;
import javax.crypto.Mac; import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec; import javax.crypto.spec.SecretKeySpec;
@@ -26,7 +27,7 @@ public class WxMaInternetServiceImpl implements WxMaInternetService {
private String sha256(String data, String sessionKey) throws Exception { private String sha256(String data, String sessionKey) throws Exception {
Mac sha256_HMAC = Mac.getInstance("HmacSHA256"); 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); sha256_HMAC.init(secret_key);
byte[] array = sha256_HMAC.doFinal(data.getBytes(StandardCharsets.UTF_8)); byte[] array = sha256_HMAC.doFinal(data.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
@@ -57,7 +58,7 @@ public class WxMaInternetServiceImpl implements WxMaInternetService {
private WxMaInternetResponse getWxMaInternetResponse(String url) throws WxErrorException { private WxMaInternetResponse getWxMaInternetResponse(String url) throws WxErrorException {
String responseContent = this.wxMaService.post(url, ""); String responseContent = this.wxMaService.post(url, "");
WxMaInternetResponse response = WxMaGsonBuilder.create().fromJson(responseContent, WxMaInternetResponse.class); 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)); throw new WxErrorException(WxError.fromJson(responseContent, WxType.MiniApp));
} }
return response; return response;

View File

@@ -44,7 +44,7 @@ public class WxMaInternetUserKeyInfo implements Serializable {
private Long expireIn; private Long expireIn;
/** /**
* 加密iv * 加密ivHex 编码,通常为 32 位十六进制字符,解码后为 16 字节,用于 AES-128-CBC
*/ */
private String iv; private String iv;

View File

@@ -84,4 +84,103 @@ public class WxMaCryptUtils extends me.chanjar.weixin.common.util.crypto.WxCrypt
} }
} }
/**
* 使用用户加密 key 对数据进行 AES-128-CBC 解密(用于小程序加密网络通道).
*
* <pre>
* 参考文档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 字节)
* </pre>
*
* @param encryptKey 用户加密 keyBase64 编码,解码后须为 16 字节)
* @param hexIv 加密 ivHex 编码,须为 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 加密(用于小程序加密网络通道).
*
* <pre>
* 参考文档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 字节)
* </pre>
*
* @param encryptKey 用户加密 keyBase64 编码,解码后须为 16 字节)
* @param hexIv 加密 ivHex 编码,须为 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;
}
} }

View File

@@ -4,6 +4,7 @@ package cn.binarywang.wx.miniapp.util.crypt;
import org.testng.annotations.*; import org.testng.annotations.*;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/** /**
* <pre> * <pre>
@@ -14,6 +15,11 @@ import static org.assertj.core.api.Assertions.assertThat;
* @author <a href="https://github.com/binarywang">Binary Wang</a> * @author <a href="https://github.com/binarywang">Binary Wang</a>
*/ */
public class WxMaCryptUtilsTest { public class WxMaCryptUtilsTest {
// 模拟来自 getUserEncryptKey 接口返回的 encrypt_keyBase64解码后 16 字节)
// 和 ivHex32 位十六进制字符,解码后 16 字节AES-128-CBC 要求)
private static final String ENCRYPT_KEY = "VI6BpyrK9XH4i4AIGe86tg==";
private static final String HEX_IV = "6003f73ec441c3866003f73ec441c386";
@Test @Test
public void testDecrypt() { public void testDecrypt() {
String sessionKey = "7MG7jbTToVVRWRXVA885rg=="; String sessionKey = "7MG7jbTToVVRWRXVA885rg==";
@@ -32,4 +38,98 @@ public class WxMaCryptUtilsTest {
assertThat(WxMaCryptUtils.decrypt(sessionKey, encryptedData, ivStr)) assertThat(WxMaCryptUtils.decrypt(sessionKey, encryptedData, ivStr))
.isEqualTo(WxMaCryptUtils.decryptAnotherWay(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 字节");
}
} }