Browse Source

feat(统一支付): 签名器与自造向量

alphaH 1 week ago
parent
commit
9ab73ded2a

+ 148 - 0
java/src/main/java/com/payment/platform/module/payment/unified/client/UnifiedSigner.java

@@ -0,0 +1,148 @@
+package com.payment.platform.module.payment.unified.client;
+
+import com.fasterxml.jackson.databind.JsonNode;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HexFormat;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 统一支付(汇安融)MD5 签名与验签(spec §4.2 / §4.3 / §4.4)。
+ *
+ * <p>签名算法:剔除 {@code sign} 与空值,按参数名排序拼成 {@code k=v&k=v},
+ * 末尾追加 {@code &key=<appSecret>},UTF-8 取 MD5 后转大写。没有可签参数时,
+ * 待签串就是 {@code key=<appSecret>}(前面没有 {@code &})。
+ *
+ * <p>排序取 ASCII 顺序(spec §4.2 的绑定口径)。上游 SDK 用的是
+ * {@link String#CASE_INSENSITIVE_ORDER},但对真实字段集两种排序输出逐字相同,
+ * {@code UnifiedSignerTest} 的「两序一致」测试锁住该前提;新增字段使两序分叉时会先红。
+ */
+public final class UnifiedSigner {
+
+    /** 不参与签名的字段名。 */
+    private static final String SIGN_FIELD = "sign";
+
+    private UnifiedSigner() {
+    }
+
+    /**
+     * 对参数集合签名。
+     *
+     * @param params    参与签名的参数;{@code sign}、null 值与空串值会被跳过
+     * @param appSecret 商户密钥
+     * @return 32 位大写 MD5
+     */
+    public static String sign(Map<String, ?> params, String appSecret) {
+        String canonical = canonical(params);
+        String content = canonical.isEmpty()
+                ? "key=" + appSecret
+                : canonical + "&key=" + appSecret;
+        return md5Upper(content);
+    }
+
+    /**
+     * 验响应 {@code data} 的签名(spec §4.3)。
+     *
+     * <p>{@code data} 为空(null / JSON null / 空对象)且 {@code sign} 为空时跳过验签返回
+     * true —— 上游错误响应就是这种形态;其余情况签名不符一律 false,由调用方抛异常。
+     *
+     * @param data      响应 data 节点,可为 null
+     * @param sign      上游给的签名,可为 null
+     * @param appSecret 商户密钥
+     */
+    public static boolean verifyData(JsonNode data, String sign, String appSecret) {
+        boolean blankSign = sign == null || sign.isBlank();
+        if (isEmptyData(data)) {
+            return blankSign;
+        }
+        if (blankSign) {
+            return false;
+        }
+        Map<String, String> params = new LinkedHashMap<>();
+        data.fields().forEachRemaining(field -> {
+            JsonNode value = field.getValue();
+            if (value == null || value.isNull()) {
+                return;
+            }
+            // 与上游 SDK 对齐:标量取文本,嵌套对象/数组取紧凑 JSON 文本
+            String text = (value.isObject() || value.isArray()) ? value.toString() : value.asText();
+            if (!text.isEmpty()) {
+                params.put(field.getKey(), text);
+            }
+        });
+        return sign(params, appSecret).equalsIgnoreCase(sign);
+    }
+
+    /**
+     * 验通知参数签名(spec §4.4)。
+     *
+     * <p>{@code sign} 由 {@link #canonical} 统一剔除,无需复制参数表。
+     * 缺少 {@code sign} 参数时返回 false(不抛 NPE)。
+     *
+     * @param params    通知参数(form-urlencoded 或 JSON 拍平后的单值表)
+     * @param appSecret 商户密钥
+     */
+    public static boolean verifyNotification(Map<String, String> params, String appSecret) {
+        if (params == null) {
+            return false;
+        }
+        String provided = params.get(SIGN_FIELD);
+        if (provided == null) {
+            return false;
+        }
+        return sign(params, appSecret).equalsIgnoreCase(provided);
+    }
+
+    /**
+     * 拼待签串:剔除 {@code sign}、null 值与空串值,按参数名 ASCII 顺序以 {@code &} 连接。
+     *
+     * <p>包内可见,供「两序一致」测试直接比对排序结果。
+     */
+    static String canonical(Map<String, ?> params) {
+        if (params == null || params.isEmpty()) {
+            return "";
+        }
+        List<String> keys = new ArrayList<>(params.keySet());
+        keys.sort(Comparator.naturalOrder());
+        StringBuilder sb = new StringBuilder();
+        for (String key : keys) {
+            if (SIGN_FIELD.equals(key)) {
+                continue;
+            }
+            Object value = params.get(key);
+            if (value == null) {
+                continue;
+            }
+            String text = String.valueOf(value);
+            if (text.isEmpty()) {
+                continue;
+            }
+            if (sb.length() > 0) {
+                sb.append('&');
+            }
+            sb.append(key).append('=').append(text);
+        }
+        return sb.toString();
+    }
+
+    private static boolean isEmptyData(JsonNode data) {
+        return data == null || data.isNull() || (data.isObject() && data.isEmpty());
+    }
+
+    private static String md5Upper(String content) {
+        try {
+            MessageDigest md5 = MessageDigest.getInstance("MD5");
+            byte[] digest = md5.digest(content.getBytes(StandardCharsets.UTF_8));
+            return HexFormat.of().withUpperCase().formatHex(digest);
+        } catch (NoSuchAlgorithmException e) {
+            // 每个 JRE 都必须提供 MD5,走到这里说明运行环境损坏
+            throw new IllegalStateException("JVM 不支持 MD5", e);
+        }
+    }
+}

+ 136 - 0
java/src/test/java/com/payment/platform/module/payment/unified/client/UnifiedSignerTest.java

@@ -0,0 +1,136 @@
+package com.payment.platform.module.payment.unified.client;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.NullNode;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 统一支付签名器(spec §4.2 / Task 3 brief)。
+ *
+ * <p>向量均用 {@code md5sum} 在 UTF-8 原文上独立算过,不是从实现里回抄的。
+ * 排序规则按 spec §4.2 的 ASCII 顺序;另有一条「两序一致」测试锁住
+ * 「对真实字段集,ASCII 与大小写不敏感两种排序输出相同」这一前提。
+ */
+class UnifiedSignerTest {
+
+    private static final String SECRET = "secret123";
+
+    /** 测试用裸 ObjectMapper:上游报文不是 SNAKE_CASE,不能用应用全局实例。 */
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+
+    @Test
+    void sign_matchesMeasuredVector() {
+        Map<String, Object> p = new LinkedHashMap<>();
+        p.put("subject", "测试");
+        p.put("amount", 100);
+        p.put("mchNo", "M1000");
+        p.put("appId", "A1");
+        assertEquals("C91D24D1C2C70EBF89F0DF58E7979DC9", UnifiedSigner.sign(p, SECRET));
+    }
+
+    @Test
+    void blankValues_areSkipped() {
+        Map<String, Object> p = new LinkedHashMap<>();
+        p.put("a", 1);
+        p.put("b", "");
+        p.put("c", null);
+        assertEquals("AFFDCC88244C83F871BFE4854BE9C1A5", UnifiedSigner.sign(p, "k"));
+    }
+
+    @Test
+    void notification_ignoresSignAndMatchesVector() {
+        Map<String, String> p = new LinkedHashMap<>();
+        p.put("state", "2");
+        p.put("payOrderId", "P1");
+        p.put("mchNo", "M1000");
+        p.put("sign", "IGNORED");
+        assertEquals("81948D582EFE91AB607513B35AA50C04",
+                UnifiedSigner.sign(p, SECRET));
+        // 验签比对的是上游送来的 sign 本身,占位串 "IGNORED" 不可能等于算出的摘要;
+        // 换成正确签名必须通过,换错密钥必须拒绝。
+        assertFalse(UnifiedSigner.verifyNotification(p, SECRET));
+        p.put("sign", "81948D582EFE91AB607513B35AA50C04");
+        assertTrue(UnifiedSigner.verifyNotification(p, SECRET));
+        assertFalse(UnifiedSigner.verifyNotification(p, "other"));
+    }
+
+    @Test
+    void emptyParams_signsKeyOnly() {
+        assertEquals("3D18F19D605404495C924CF600E489C6",
+                UnifiedSigner.sign(new LinkedHashMap<>(), SECRET));
+    }
+
+    @Test
+    void responseData_nestedObjectUsesCompactJson() throws Exception {
+        JsonNode data = MAPPER.readTree(
+                "{\"state\":\"2\",\"channelRetMsg\":{\"channelErrCode\":\"E1\"}}");
+        assertTrue(UnifiedSigner.verifyData(data, "12448BEA270AC8E2D2A8BBA7803B77BC", SECRET));
+        assertFalse(UnifiedSigner.verifyData(data, "00000000000000000000000000000000", SECRET));
+        assertFalse(UnifiedSigner.verifyData(data, null, SECRET));
+        assertFalse(UnifiedSigner.verifyData(data, "", SECRET));
+    }
+
+    @Test
+    void emptyData_withBlankSign_passes() throws Exception {
+        assertTrue(UnifiedSigner.verifyData(null, null, SECRET));
+        assertTrue(UnifiedSigner.verifyData(NullNode.getInstance(), " ", SECRET));
+        assertTrue(UnifiedSigner.verifyData(MAPPER.readTree("{}"), null, SECRET));
+        assertFalse(UnifiedSigner.verifyData(MAPPER.readTree("{}"), "ABC", SECRET));
+    }
+
+    @Test
+    void verifyNotification_missingSign_returnsFalse() {
+        Map<String, String> p = new LinkedHashMap<>();
+        p.put("state", "2");
+        assertFalse(UnifiedSigner.verifyNotification(p, SECRET));
+    }
+
+    @Test
+    void asciiAndCaseInsensitiveOrdersAgreeForRealFieldSet() {
+        // spec §4.2 列出的真实请求字段集。两边排序结果相同是本实现的成立前提;
+        // 将来新增首字母大小写与 ASCII 次序相左的字段时,这条会先红。
+        Map<String, Object> p = new LinkedHashMap<>();
+        p.put("amount", "100");
+        p.put("appId", "A1");
+        p.put("body", "b");
+        p.put("channelExtra", "{}");
+        p.put("clientIp", "127.0.0.1");
+        p.put("currency", "cny");
+        p.put("extParam", "e");
+        p.put("mchNo", "M1000");
+        p.put("mchOrderNo", "O1");
+        p.put("notifyUrl", "https://example.test/notify");
+        p.put("reqTime", "20261020120000");
+        p.put("returnUrl", "https://example.test/return");
+        p.put("signType", "MD5");
+        p.put("subject", "s");
+        p.put("version", "1.0");
+        p.put("wayCode", "WX_JSAPI");
+
+        assertEquals(canonicalUnder(p, String.CASE_INSENSITIVE_ORDER), UnifiedSigner.canonical(p));
+    }
+
+    private static String canonicalUnder(Map<String, Object> params, Comparator<String> order) {
+        List<String> keys = new ArrayList<>(params.keySet());
+        keys.sort(order);
+        StringBuilder sb = new StringBuilder();
+        for (String key : keys) {
+            if (sb.length() > 0) {
+                sb.append('&');
+            }
+            sb.append(key).append('=').append(params.get(key));
+        }
+        return sb.toString();
+    }
+}