Skip to content

Commit bcb3110

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

4 files changed

Lines changed: 203 additions & 3 deletions

File tree

weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/api/impl/WxMaInternetServiceImpl.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import me.chanjar.weixin.common.enums.WxType;
1010
import me.chanjar.weixin.common.error.WxError;
1111
import me.chanjar.weixin.common.error.WxErrorException;
12+
import org.apache.commons.codec.binary.Base64;
1213

1314
import javax.crypto.Mac;
1415
import javax.crypto.spec.SecretKeySpec;
@@ -26,7 +27,7 @@ public class WxMaInternetServiceImpl implements WxMaInternetService {
2627

2728
private String sha256(String data, String sessionKey) throws Exception {
2829
Mac sha256_HMAC = Mac.getInstance("HmacSHA256");
29-
SecretKeySpec secret_key = new SecretKeySpec(sessionKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
30+
SecretKeySpec secret_key = new SecretKeySpec(Base64.decodeBase64(sessionKey), "HmacSHA256");
3031
sha256_HMAC.init(secret_key);
3132
byte[] array = sha256_HMAC.doFinal(data.getBytes(StandardCharsets.UTF_8));
3233
StringBuilder sb = new StringBuilder();
@@ -57,7 +58,7 @@ public WxMaInternetResponse getUserEncryptKey(String openid, String sessionKey)
5758
private WxMaInternetResponse getWxMaInternetResponse(String url) throws WxErrorException {
5859
String responseContent = this.wxMaService.post(url, "");
5960
WxMaInternetResponse response = WxMaGsonBuilder.create().fromJson(responseContent, WxMaInternetResponse.class);
60-
if (response.getErrcode() == -1) {
61+
if (response.getErrcode() != null && response.getErrcode() != 0) {
6162
throw new WxErrorException(WxError.fromJson(responseContent, WxType.MiniApp));
6263
}
6364
return response;

weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/bean/internet/WxMaInternetUserKeyInfo.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ public class WxMaInternetUserKeyInfo implements Serializable {
4444
private Long expireIn;
4545

4646
/**
47-
* 加密iv
47+
* 加密iv(Hex 编码,通常为 32 位十六进制字符,解码后为 16 字节,用于 AES-128-CBC)
4848
*/
4949
private String iv;
5050

weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/util/crypt/WxMaCryptUtils.java

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,4 +84,103 @@ public static String decryptAnotherWay(String sessionKey, String encryptedData,
8484
}
8585
}
8686

87+
/**
88+
* 使用用户加密 key 对数据进行 AES-128-CBC 解密(用于小程序加密网络通道).
89+
*
90+
* <pre>
91+
* 参考文档:https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/user-encryptkey.html
92+
* encryptKey 来自 getUserEncryptKey 接口返回的 encrypt_key 字段(Base64 编码,解码后须为 16 字节)
93+
* hexIv 来自 getUserEncryptKey 接口返回的 iv 字段(Hex 编码,须为 32 位十六进制字符,解码后为 16 字节)
94+
* </pre>
95+
*
96+
* @param encryptKey 用户加密 key(Base64 编码,解码后须为 16 字节)
97+
* @param hexIv 加密 iv(Hex 编码,须为 32 位十六进制字符)
98+
* @param encryptedData 加密数据(Base64 编码)
99+
* @return 解密后的字符串
100+
* @throws IllegalArgumentException 如果 encryptKey 解码后不为 16 字节,或 hexIv 格式非法/解码后不为 16 字节
101+
*/
102+
public static String decryptWithEncryptKey(String encryptKey, String hexIv, String encryptedData) {
103+
byte[] keyBytes = Base64.decodeBase64(encryptKey);
104+
if (keyBytes.length != 16) {
105+
throw new IllegalArgumentException(
106+
"encryptKey 解码后必须为 16 字节(AES-128),实际为 " + keyBytes.length + " 字节");
107+
}
108+
byte[] ivBytes = hexToBytes(hexIv);
109+
if (ivBytes.length != 16) {
110+
throw new IllegalArgumentException(
111+
"hexIv 解码后必须为 16 字节(AES-128-CBC),实际为 " + ivBytes.length + " 字节(需 32 位 Hex 字符串)");
112+
}
113+
byte[] dataBytes = Base64.decodeBase64(encryptedData);
114+
try {
115+
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
116+
cipher.init(Cipher.DECRYPT_MODE,
117+
new SecretKeySpec(keyBytes, "AES"),
118+
new IvParameterSpec(ivBytes));
119+
return new String(cipher.doFinal(dataBytes), UTF_8);
120+
} catch (Exception e) {
121+
throw new WxRuntimeException("AES解密失败!", e);
122+
}
123+
}
124+
125+
/**
126+
* 使用用户加密 key 对数据进行 AES-128-CBC 加密(用于小程序加密网络通道).
127+
*
128+
* <pre>
129+
* 参考文档:https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/user-encryptkey.html
130+
* encryptKey 来自 getUserEncryptKey 接口返回的 encrypt_key 字段(Base64 编码,解码后须为 16 字节)
131+
* hexIv 来自 getUserEncryptKey 接口返回的 iv 字段(Hex 编码,须为 32 位十六进制字符,解码后为 16 字节)
132+
* </pre>
133+
*
134+
* @param encryptKey 用户加密 key(Base64 编码,解码后须为 16 字节)
135+
* @param hexIv 加密 iv(Hex 编码,须为 32 位十六进制字符)
136+
* @param data 待加密的明文字符串
137+
* @return 加密后的数据(Base64 编码)
138+
* @throws IllegalArgumentException 如果 encryptKey 解码后不为 16 字节,或 hexIv 格式非法/解码后不为 16 字节
139+
*/
140+
public static String encryptWithEncryptKey(String encryptKey, String hexIv, String data) {
141+
byte[] keyBytes = Base64.decodeBase64(encryptKey);
142+
if (keyBytes.length != 16) {
143+
throw new IllegalArgumentException(
144+
"encryptKey 解码后必须为 16 字节(AES-128),实际为 " + keyBytes.length + " 字节");
145+
}
146+
byte[] ivBytes = hexToBytes(hexIv);
147+
if (ivBytes.length != 16) {
148+
throw new IllegalArgumentException(
149+
"hexIv 解码后必须为 16 字节(AES-128-CBC),实际为 " + ivBytes.length + " 字节(需 32 位 Hex 字符串)");
150+
}
151+
try {
152+
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
153+
cipher.init(Cipher.ENCRYPT_MODE,
154+
new SecretKeySpec(keyBytes, "AES"),
155+
new IvParameterSpec(ivBytes));
156+
return Base64.encodeBase64String(cipher.doFinal(data.getBytes(UTF_8)));
157+
} catch (Exception e) {
158+
throw new WxRuntimeException("AES加密失败!", e);
159+
}
160+
}
161+
162+
/**
163+
* 将 Hex 字符串转换为字节数组.
164+
*
165+
* @param hex Hex 字符串(长度必须为偶数,只包含 0-9 和 a-f/A-F 字符)
166+
* @return 字节数组
167+
* @throws IllegalArgumentException 如果输入不是合法的 Hex 字符串
168+
*/
169+
private static byte[] hexToBytes(String hex) {
170+
if (hex == null || hex.length() % 2 != 0) {
171+
throw new IllegalArgumentException("无效的十六进制字符串格式:长度必须为偶数");
172+
}
173+
int len = hex.length();
174+
byte[] data = new byte[len / 2];
175+
for (int i = 0; i < len; i += 2) {
176+
int high = Character.digit(hex.charAt(i), 16);
177+
int low = Character.digit(hex.charAt(i + 1), 16);
178+
if (high == -1 || low == -1) {
179+
throw new IllegalArgumentException("无效的十六进制字符串格式:包含非法字符 '" + hex.charAt(high == -1 ? i : i + 1) + "'");
180+
}
181+
data[i / 2] = (byte) ((high << 4) + low);
182+
}
183+
return data;
184+
}
185+
87186
}

weixin-java-miniapp/src/test/java/cn/binarywang/wx/miniapp/util/crypt/WxMaCryptUtilsTest.java

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import org.testng.annotations.*;
55

66
import static org.assertj.core.api.Assertions.assertThat;
7+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
78

89
/**
910
* <pre>
@@ -14,6 +15,11 @@
1415
* @author <a href="https://github.com/binarywang">Binary Wang</a>
1516
*/
1617
public class WxMaCryptUtilsTest {
18+
// 模拟来自 getUserEncryptKey 接口返回的 encrypt_key(Base64,解码后 16 字节)
19+
// 和 iv(Hex,32 位十六进制字符,解码后 16 字节,AES-128-CBC 要求)
20+
private static final String ENCRYPT_KEY = "VI6BpyrK9XH4i4AIGe86tg==";
21+
private static final String HEX_IV = "6003f73ec441c3866003f73ec441c386";
22+
1723
@Test
1824
public void testDecrypt() {
1925
String sessionKey = "7MG7jbTToVVRWRXVA885rg==";
@@ -32,4 +38,98 @@ public void testDecryptAnotherWay() {
3238
assertThat(WxMaCryptUtils.decrypt(sessionKey, encryptedData, ivStr))
3339
.isEqualTo(WxMaCryptUtils.decryptAnotherWay(sessionKey, encryptedData, ivStr));
3440
}
41+
42+
/**
43+
* 测试使用用户加密 key(来自小程序加密网络通道)进行加密和解密的对称性.
44+
* encrypt_key 为 Base64 编码的 16 字节 AES-128 密钥,iv 为 Hex 编码的 16 字节初始向量。
45+
*/
46+
@Test
47+
public void testEncryptAndDecryptWithEncryptKey() {
48+
String plainText = "{\"userId\":\"12345\",\"amount\":100}";
49+
50+
String encrypted = WxMaCryptUtils.encryptWithEncryptKey(ENCRYPT_KEY, HEX_IV, plainText);
51+
assertThat(encrypted).isNotNull().isNotEmpty();
52+
53+
String decrypted = WxMaCryptUtils.decryptWithEncryptKey(ENCRYPT_KEY, HEX_IV, encrypted);
54+
assertThat(decrypted).isEqualTo(plainText);
55+
}
56+
57+
/**
58+
* 测试加密网络通道的加解密对称性(不同明文).
59+
*/
60+
@Test
61+
public void testEncryptDecryptSymmetryWithEncryptKey() {
62+
String plainText = "hello miniprogram";
63+
64+
String encrypted = WxMaCryptUtils.encryptWithEncryptKey(ENCRYPT_KEY, HEX_IV, plainText);
65+
String decrypted = WxMaCryptUtils.decryptWithEncryptKey(ENCRYPT_KEY, HEX_IV, encrypted);
66+
assertThat(decrypted).isEqualTo(plainText);
67+
}
68+
69+
/**
70+
* 测试 hexIv 为奇数长度时,应抛出 IllegalArgumentException.
71+
*/
72+
@Test
73+
public void testEncryptWithEncryptKeyInvalidHexIvOddLength() {
74+
assertThatThrownBy(() -> WxMaCryptUtils.encryptWithEncryptKey(ENCRYPT_KEY, "abc", "data"))
75+
.isInstanceOf(IllegalArgumentException.class)
76+
.hasMessageContaining("长度必须为偶数");
77+
}
78+
79+
/**
80+
* 测试 hexIv 包含非十六进制字符时,应抛出 IllegalArgumentException.
81+
*/
82+
@Test
83+
public void testEncryptWithEncryptKeyInvalidHexIvNonHexChar() {
84+
// 32 位但含非法字符 'z'
85+
assertThatThrownBy(() -> WxMaCryptUtils.encryptWithEncryptKey(
86+
ENCRYPT_KEY, "6003f73ec441c3866003f73ec441z386", "data"))
87+
.isInstanceOf(IllegalArgumentException.class)
88+
.hasMessageContaining("非法字符");
89+
}
90+
91+
/**
92+
* 测试 hexIv 解码后不足 16 字节(如仅 16 位 hex = 8 字节)时,应抛出 IllegalArgumentException.
93+
*/
94+
@Test
95+
public void testEncryptWithEncryptKeyShortHexIv() {
96+
// 16 位 hex = 8 字节,不满足 AES-CBC 要求的 16 字节
97+
assertThatThrownBy(() -> WxMaCryptUtils.encryptWithEncryptKey(
98+
ENCRYPT_KEY, "6003f73ec441c386", "data"))
99+
.isInstanceOf(IllegalArgumentException.class)
100+
.hasMessageContaining("hexIv 解码后必须为 16 字节");
101+
}
102+
103+
/**
104+
* 测试 encryptKey 解码后不足 16 字节时,应抛出 IllegalArgumentException.
105+
*/
106+
@Test
107+
public void testEncryptWithEncryptKeyShortKey() {
108+
// Base64 编码的 8 字节 key(不符合 AES-128 要求)
109+
String shortKey = java.util.Base64.getEncoder().encodeToString(new byte[8]);
110+
assertThatThrownBy(() -> WxMaCryptUtils.encryptWithEncryptKey(shortKey, HEX_IV, "data"))
111+
.isInstanceOf(IllegalArgumentException.class)
112+
.hasMessageContaining("encryptKey 解码后必须为 16 字节");
113+
}
114+
115+
/**
116+
* 测试 decryptWithEncryptKey 使用非法 hexIv 时,应抛出 IllegalArgumentException.
117+
*/
118+
@Test
119+
public void testDecryptWithEncryptKeyInvalidHexIv() {
120+
assertThatThrownBy(() -> WxMaCryptUtils.decryptWithEncryptKey(ENCRYPT_KEY, "abc", "data"))
121+
.isInstanceOf(IllegalArgumentException.class)
122+
.hasMessageContaining("长度必须为偶数");
123+
}
124+
125+
/**
126+
* 测试 decryptWithEncryptKey encryptKey 长度不合法时,应抛出 IllegalArgumentException.
127+
*/
128+
@Test
129+
public void testDecryptWithEncryptKeyShortKey() {
130+
String shortKey = java.util.Base64.getEncoder().encodeToString(new byte[8]);
131+
assertThatThrownBy(() -> WxMaCryptUtils.decryptWithEncryptKey(shortKey, HEX_IV, "data"))
132+
.isInstanceOf(IllegalArgumentException.class)
133+
.hasMessageContaining("encryptKey 解码后必须为 16 字节");
134+
}
35135
}

0 commit comments

Comments
 (0)