Jelajahi Sumber

feat: implement official OMG check code signer

qmj 2 minggu lalu
induk
melakukan
2c9321da9e

+ 73 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgCheckMacSigner.java

@@ -0,0 +1,73 @@
+package com.ruoyi.app.omgpay;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.HexFormat;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.TreeMap;
+import java.util.stream.Collectors;
+
+public class OmgCheckMacSigner {
+
+    public String sign(Map<String, String> fields, String hashKey, String hashIv) {
+        requireSecrets(hashKey, hashIv);
+        TreeMap<String, String> sorted = validatedCopy(fields);
+        String query = sorted.entrySet().stream()
+                .map(entry -> entry.getKey() + "=" + entry.getValue())
+                .collect(Collectors.joining("&"));
+        String raw = "HashKey=" + hashKey + "&" + query + "&HashIV=" + hashIv;
+        String encoded = dotNetUrlEncode(raw).toLowerCase(Locale.ROOT);
+        return HexFormat.of().withUpperCase().formatHex(sha256(encoded));
+    }
+
+    private static void requireSecrets(String hashKey, String hashIv) {
+        requireNonNull("hashKey", hashKey);
+        requireNonNull("hashIv", hashIv);
+    }
+
+    private static TreeMap<String, String> validatedCopy(Map<String, String> fields) {
+        Objects.requireNonNull(fields, "fields must not be null");
+        TreeMap<String, String> sorted = new TreeMap<>();
+        for (Map.Entry<String, String> entry : fields.entrySet()) {
+            String key = entry.getKey();
+            String value = entry.getValue();
+            requireNonNull("field key", key);
+            requireNonNull("field value", value);
+            if ("CheckMacValue".equals(key)) {
+                throw new IllegalArgumentException("CheckMacValue must not be supplied");
+            }
+            sorted.put(key, value);
+        }
+        return sorted;
+    }
+
+    private static void requireNonNull(String name, String value) {
+        if (value == null) {
+            throw new IllegalArgumentException(name + " must not be null");
+        }
+    }
+
+    private static String dotNetUrlEncode(String raw) {
+        String encoded = URLEncoder.encode(raw, StandardCharsets.UTF_8);
+        // OMG's official appendix uses .NET URL conversion before lowercasing and hashing.
+        return encoded.replace("%2D", "-")
+                .replace("%5F", "_")
+                .replace("%2E", ".")
+                .replace("%21", "!")
+                .replace("%2A", "*")
+                .replace("%28", "(")
+                .replace("%29", ")");
+    }
+
+    private static byte[] sha256(String value) {
+        try {
+            return MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));
+        } catch (NoSuchAlgorithmException exception) {
+            throw new IllegalStateException("SHA-256 not available", exception);
+        }
+    }
+}

+ 74 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgCheckMacSignerTest.java

@@ -0,0 +1,74 @@
+package com.ruoyi.app.omgpay;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class OmgCheckMacSignerTest {
+
+    private static final String HASH_KEY = "5294y06JbISpM5x9";
+    private static final String HASH_IV = "v77hoKGq4kWxNNIS";
+    private static final String EXPECTED_SIGNATURE =
+            "AA5842FDA7E55ACEB7118D6353E9822CA6D6FF09A0D1FC129A879DD5CAF93266";
+
+    private final OmgCheckMacSigner signer = new OmgCheckMacSigner();
+
+    @Test
+    void signsOfficialAppendixVector() {
+        assertEquals(EXPECTED_SIGNATURE, signer.sign(officialFields(), HASH_KEY, HASH_IV));
+    }
+
+    @Test
+    void includesAdditionalAndEmptyFieldsInSignature() {
+        Map<String, String> fields = officialFields();
+
+        assertNotEquals(signer.sign(fields, HASH_KEY, HASH_IV),
+                signer.sign(withExtraField(fields, "NeedExtraPaidInfo", "Y"), HASH_KEY, HASH_IV));
+        assertNotEquals(signer.sign(fields, HASH_KEY, HASH_IV),
+                signer.sign(withExtraField(fields, "EmptyExtra", ""), HASH_KEY, HASH_IV));
+    }
+
+    @Test
+    void rejectsCallerSuppliedCheckMacValue() {
+        assertThrows(IllegalArgumentException.class,
+                () -> signer.sign(Map.of("CheckMacValue", "caller-value"), HASH_KEY, HASH_IV));
+    }
+
+    @Test
+    void leavesInputUnchangedAndReturnsUppercaseSha256Hex() {
+        Map<String, String> fields = new LinkedHashMap<>(officialFields());
+        Map<String, String> snapshot = new LinkedHashMap<>(fields);
+
+        String signature = signer.sign(fields, HASH_KEY, HASH_IV);
+
+        assertEquals(snapshot, fields);
+        assertTrue(signature.matches("[0-9A-F]{64}"));
+    }
+
+    private static Map<String, String> officialFields() {
+        Map<String, String> fields = new LinkedHashMap<>();
+        fields.put("TradeDesc", "促銷方案");
+        fields.put("PaymentType", "aio");
+        fields.put("MerchantTradeDate", "2013/03/12 15:30:23");
+        fields.put("MerchantTradeNo", "funpoint20130312153023");
+        fields.put("MerchantID", "2000132");
+        fields.put("ReturnURL", "https://www.funpoint.com.tw/receive.php");
+        fields.put("ItemName", "Apple iphone 7 手機殼");
+        fields.put("TotalAmount", "1000");
+        fields.put("ChoosePayment", "ALL");
+        fields.put("EncryptType", "1");
+        return fields;
+    }
+
+    private static Map<String, String> withExtraField(Map<String, String> fields, String key, String value) {
+        Map<String, String> updated = new LinkedHashMap<>(fields);
+        updated.put(key, value);
+        return updated;
+    }
+}