|
|
@@ -0,0 +1,219 @@
|
|
|
+package com.ruoyi.app.utils.ezPayCrypto;
|
|
|
+
|
|
|
+import javax.crypto.Cipher;
|
|
|
+import javax.crypto.spec.IvParameterSpec;
|
|
|
+import javax.crypto.spec.SecretKeySpec;
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
+import java.security.MessageDigest;
|
|
|
+import java.util.Arrays;
|
|
|
+import java.util.HexFormat;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.TreeMap;
|
|
|
+
|
|
|
+/**
|
|
|
+ * ezPay 电子发票加值服务平台 加密 / 签名工具。
|
|
|
+ *
|
|
|
+ * <p>对应官方手册「附件一 PostData_ 加密方法」「附件二 CheckCode 产生规则」的 PHP / C# 示例。
|
|
|
+ *
|
|
|
+ * <p><b>关键点</b>:ezPay 要求 PKCS7 填充块为 <b>32 字节</b>(非 AES 标准的 16),再用
|
|
|
+ * AES-256-CBC(NoPadding) 加密。因为 32 是 16 的倍数,填充后的数据对标准 AES-256-CBC 合法,
|
|
|
+ * 无需 BouncyCastle,标准 javax.crypto 即可。
|
|
|
+ *
|
|
|
+ * <ul>
|
|
|
+ * <li>HashKey 固定 32 字节(→ AES-256),HashIV 固定 16 字节。</li>
|
|
|
+ * <li>加密结果输出小写 hex,即请求里的 PostData_。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p>自测:运行 {@link #main} 会用官方手册里的示例数据验证 CheckValue / CheckCode /
|
|
|
+ * 加解密 round-trip,全部 PASS 才算实现正确。
|
|
|
+ *
|
|
|
+ * @author ruoyi
|
|
|
+ */
|
|
|
+public class EzPayEncryptUtil {
|
|
|
+
|
|
|
+ /** ezPay 特殊的 PKCS7 填充块大小(字节),官方手册固定为 32。 */
|
|
|
+ private static final int BLOCK_SIZE = 32;
|
|
|
+
|
|
|
+ private static final String TRANSFORMATION = "AES/CBC/NoPadding";
|
|
|
+
|
|
|
+ private EzPayEncryptUtil() {
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * AES-256-CBC 加密 → 小写 hex(用于 PostData_)。
|
|
|
+ *
|
|
|
+ * @param data 明文,参数已按 http_build_query 形式拼成 {@code a=1&b=2}
|
|
|
+ * @param hashKey 商店 HashKey(固定 32 字节)
|
|
|
+ * @param hashIV 商店 HashIV(固定 16 字节)
|
|
|
+ * @return 小写 hex 密文,放入请求的 PostData_
|
|
|
+ */
|
|
|
+ public static String encrypt(String data, String hashKey, String hashIV) {
|
|
|
+ try {
|
|
|
+ byte[] padded = pkcs7Pad(data.getBytes(StandardCharsets.UTF_8), BLOCK_SIZE);
|
|
|
+ Cipher cipher = Cipher.getInstance(TRANSFORMATION);
|
|
|
+ cipher.init(Cipher.ENCRYPT_MODE,
|
|
|
+ new SecretKeySpec(hashKey.getBytes(StandardCharsets.UTF_8), "AES"),
|
|
|
+ new IvParameterSpec(hashIV.getBytes(StandardCharsets.UTF_8)));
|
|
|
+ byte[] encrypted = cipher.doFinal(padded);
|
|
|
+ return HexFormat.of().formatHex(encrypted);
|
|
|
+ } catch (Exception e) {
|
|
|
+ throw new RuntimeException("ezPay PostData_ 加密失败", e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * AES-256-CBC 解密(用于解密回应里的 Result 字段)。
|
|
|
+ *
|
|
|
+ * @param hexData 小写 hex 密文
|
|
|
+ * @return 解密后的明文(通常是 a=1&b=2 形式或 JSON 字符串)
|
|
|
+ */
|
|
|
+ public static String decrypt(String hexData, String hashKey, String hashIV) {
|
|
|
+ try {
|
|
|
+ byte[] bytes = HexFormat.of().parseHex(hexData);
|
|
|
+ Cipher cipher = Cipher.getInstance(TRANSFORMATION);
|
|
|
+ cipher.init(Cipher.DECRYPT_MODE,
|
|
|
+ new SecretKeySpec(hashKey.getBytes(StandardCharsets.UTF_8), "AES"),
|
|
|
+ new IvParameterSpec(hashIV.getBytes(StandardCharsets.UTF_8)));
|
|
|
+ byte[] decrypted = cipher.doFinal(bytes);
|
|
|
+ return new String(pkcs7Unpad(decrypted), StandardCharsets.UTF_8);
|
|
|
+ } catch (Exception e) {
|
|
|
+ throw new RuntimeException("ezPay Result 解密失败", e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 生成请求 CheckValue:SHA256("HashKey={key}&{postDataHex}&HashIV={iv}") 转大写。
|
|
|
+ *
|
|
|
+ * <p>仅 BDV 验证类接口(checkBarCode / checkLoveCode)请求时需要带 CheckValue;
|
|
|
+ * 发票类接口(issue / invalid / search ...)请求不带 CheckValue,只发 MerchantID_ + PostData_。
|
|
|
+ *
|
|
|
+ * <p><b>文档勘误提醒</b>:BDV 手册附件三把一个「回应 CheckCode」的示例值误标在 CheckValue 章节,
|
|
|
+ * 故无法用文档示例验证本方法。本方法严格按附件三<b>文字描述</b>实现(HashKey 在前、HashIV 在后)。
|
|
|
+ * 实际接入 checkBarCode 若平台返回 CheckValue 校验失败,可改为 HashIV 在前重试
|
|
|
+ * (即与 {@link #genCheckCodeRaw} 同序,二者只差首尾 Key/IV 顺序)。
|
|
|
+ */
|
|
|
+ public static String genCheckValue(String postDataHex, String hashKey, String hashIV) {
|
|
|
+ String raw = "HashKey=" + hashKey + "&" + postDataHex + "&HashIV=" + hashIV;
|
|
|
+ return sha256Upper(raw);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 生成回应 CheckCode:将指定字段按 A~Z 排序,
|
|
|
+ * SHA256("HashIV={iv}&{k1=v1&k2=v2...}&HashKey={key}") 转大写。
|
|
|
+ *
|
|
|
+ * <p>用于校验回应确实来自 ezPay 平台。各接口参与校验的字段不同:
|
|
|
+ * <ul>
|
|
|
+ * <li>发票开立 / 触发 / 查询:InvoiceTransNo, MerchantID, MerchantOrderNo, RandomNum, TotalAmt</li>
|
|
|
+ * <li>作废发票:MerchantID, InvoiceNumber, CreateTime(参考手册各章节「附件二」)</li>
|
|
|
+ * <li>折让:MerchantID, AllowanceNo, MerchantOrderNo, AllowanceAmt, RemainAmt</li>
|
|
|
+ * </ul>
|
|
|
+ * 调用方按接口把对应字段塞进 {@code fields} 即可,键名须与官方一致。
|
|
|
+ *
|
|
|
+ * @param fields 回应中参与校验的字段
|
|
|
+ */
|
|
|
+ public static String genCheckCode(Map<String, String> fields, String hashKey, String hashIV) {
|
|
|
+ TreeMap<String, String> sorted = new TreeMap<>(fields);
|
|
|
+ StringBuilder sb = new StringBuilder("HashIV=").append(hashIV).append('&');
|
|
|
+ for (Map.Entry<String, String> e : sorted.entrySet()) {
|
|
|
+ sb.append(e.getKey()).append('=').append(e.getValue()).append('&');
|
|
|
+ }
|
|
|
+ sb.append("HashKey=").append(hashKey);
|
|
|
+ return sha256Upper(sb.toString());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 生成回应 CheckCode(BDV 验证接口风格):直接用一段原始串(如回应里的 Result 密文 hex)
|
|
|
+ * 拼接,SHA256("HashIV={iv}&{raw}&HashKey={key}") 转大写。
|
|
|
+ *
|
|
|
+ * <p>BDV 接口(checkBarCode / checkLoveCode)的回应 CheckCode 以解密前的 Result 密文 hex
|
|
|
+ * 作为唯一参与串;与发票接口(INVI)的多字段排序 CheckCode 不同,故单独提供。
|
|
|
+ */
|
|
|
+ public static String genCheckCodeRaw(String raw, String hashKey, String hashIV) {
|
|
|
+ return sha256Upper("HashIV=" + hashIV + "&" + raw + "&HashKey=" + hashKey);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String sha256Upper(String raw) {
|
|
|
+ try {
|
|
|
+ MessageDigest md = MessageDigest.getInstance("SHA-256");
|
|
|
+ byte[] digest = md.digest(raw.getBytes(StandardCharsets.UTF_8));
|
|
|
+ return HexFormat.of().formatHex(digest).toUpperCase();
|
|
|
+ } catch (Exception e) {
|
|
|
+ throw new RuntimeException("SHA256 计算失败", e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** PKCS7 填充到 blockSize 的整数倍。 */
|
|
|
+ private static byte[] pkcs7Pad(byte[] data, int blockSize) {
|
|
|
+ int pad = blockSize - (data.length % blockSize);
|
|
|
+ byte[] result = new byte[data.length + pad];
|
|
|
+ System.arraycopy(data, 0, result, 0, data.length);
|
|
|
+ for (int i = data.length; i < result.length; i++) {
|
|
|
+ result[i] = (byte) pad;
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 去除 PKCS7 填充。 */
|
|
|
+ private static byte[] pkcs7Unpad(byte[] data) {
|
|
|
+ int pad = data[data.length - 1] & 0xFF;
|
|
|
+ return Arrays.copyOf(data, data.length - pad);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ===================== 自测:用官方手册示例数据验证 =====================
|
|
|
+
|
|
|
+ public static void main(String[] args) {
|
|
|
+ int pass = 0, fail = 0;
|
|
|
+
|
|
|
+ // 1) CheckCode:INVI 手册附件二示例
|
|
|
+ // 字段 InvoiceTransNo/MerchantID/MerchantOrderNo/RandomNum/TotalAmt
|
|
|
+ // HashKey=abcdefghijklmnopqrstuvwxyzabcdef, HashIV=1234567891234567
|
|
|
+ // 期望 303AB800650B724733B5D91CBCE075D9EA09E4CDE9CD33461D45F07D5EC7EECB
|
|
|
+ Map<String, String> checkCodeFields = new java.util.HashMap<>();
|
|
|
+ checkCodeFields.put("InvoiceTransNo", "14061313541640927");
|
|
|
+ checkCodeFields.put("MerchantID", "3622183");
|
|
|
+ checkCodeFields.put("MerchantOrderNo", "201409170000001");
|
|
|
+ checkCodeFields.put("RandomNum", "0142");
|
|
|
+ checkCodeFields.put("TotalAmt", "500");
|
|
|
+ String cc = genCheckCode(checkCodeFields, "abcdefghijklmnopqrstuvwxyzabcdef", "1234567891234567");
|
|
|
+ if ("303AB800650B724733B5D91CBCE075D9EA09E4CDE9CD33461D45F07D5EC7EECB".equals(cc)) {
|
|
|
+ System.out.println("[PASS] CheckCode"); pass++;
|
|
|
+ } else {
|
|
|
+ System.out.println("[FAIL] CheckCode got=" + cc); fail++;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2) BDV 回应 CheckCode(Result 密文单字段):手册 PAGE 10 示例
|
|
|
+ // HashIV=SdsatSdXm1vH7N3T & Result hex & HashKey=rsBjnIvMG3VMMWsEynTK3PWiGccHuYiV
|
|
|
+ // 期望 052E06C3720642DE306C1C33F921713EA4BA6DB9970C78F6C98952D4E69E85F7
|
|
|
+ // 注:BDV 附件三把这个 CheckCode 值误标为 CheckValue,实际是回应 CheckCode(HashIV 在前)。
|
|
|
+ String resultHex = "0628256c389824ce257c6e8796707aa3bc803d2245f4894261dbae64ff9a51f34f519b79c3fd8892157a8703779ab7639c62c3d078e7c8a09006a27f4f8aefbb";
|
|
|
+ String cc2 = genCheckCodeRaw(resultHex, "rsBjnIvMG3VMMWsEynTK3PWiGccHuYiV", "SdsatSdXm1vH7N3T");
|
|
|
+ if ("052E06C3720642DE306C1C33F921713EA4BA6DB9970C78F6C98952D4E69E85F7".equals(cc2)) {
|
|
|
+ System.out.println("[PASS] CheckCode(BDV/Result)"); pass++;
|
|
|
+ } else {
|
|
|
+ System.out.println("[FAIL] CheckCode(BDV) got=" + cc2); fail++;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 3) 加解密 round-trip:用 32 字节 key + 16 字节 iv
|
|
|
+ String key = "abcdefghijklmnopqrstuvwxyzabcdef";
|
|
|
+ String iv = "1234567891234567";
|
|
|
+ String plain = "RespondType=JSON&Version=1.0&TimeStamp=1444963784";
|
|
|
+ String enc = encrypt(plain, key, iv);
|
|
|
+ String dec = decrypt(enc, key, iv);
|
|
|
+ if (plain.equals(dec)) {
|
|
|
+ System.out.println("[PASS] encrypt/decrypt round-trip"); pass++;
|
|
|
+ } else {
|
|
|
+ System.out.println("[FAIL] round-trip dec=" + dec); fail++;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 4) 边界:明文长度正好是 32 倍数时,应补一整块(32)
|
|
|
+ String plain2 = "0123456789ABCDEF0123456789ABCDEF"; // 32 字节
|
|
|
+ String dec2 = decrypt(encrypt(plain2, key, iv), key, iv);
|
|
|
+ if (plain2.equals(dec2)) {
|
|
|
+ System.out.println("[PASS] PKCS7 满块补整块"); pass++;
|
|
|
+ } else {
|
|
|
+ System.out.println("[FAIL] 满块补整块 dec=" + dec2); fail++;
|
|
|
+ }
|
|
|
+
|
|
|
+ System.out.println("\n结果: " + pass + " passed, " + fail + " failed");
|
|
|
+ }
|
|
|
+}
|