From a37fcac56e57dbb27c204a152e95c7762c426bd4 Mon Sep 17 00:00:00 2001
From: zj <1772600164@qq.com>
Date: Tue, 24 Mar 2026 10:54:14 +0800
Subject: [PATCH] 1
---
src/main/java/com/nq/common/PayV2GatewayKeys.java | 24 ++++++
src/main/java/com/nq/utils/pay/PayV2RsaSignUtil.java | 175 +++++++++++++++++++++++++++++++++++++++++++
2 files changed, 199 insertions(+), 0 deletions(-)
diff --git a/src/main/java/com/nq/common/PayV2GatewayKeys.java b/src/main/java/com/nq/common/PayV2GatewayKeys.java
new file mode 100644
index 0000000..14ad633
--- /dev/null
+++ b/src/main/java/com/nq/common/PayV2GatewayKeys.java
@@ -0,0 +1,24 @@
+package com.nq.common;
+
+/**
+ * Pay v2 网关 RSA 密钥(与客服配置一致)。
+ * <ul>
+ * <li><b>MERCHANT_PRIVATE_KEY_PEM</b>:官方所称「商户端私钥」,仅用于我方发起请求时的 SHA256withRSA 签名(uutool 选 PKCS8、2048 位)。</li>
+ * <li><b>MERCHANT_PUBLIC_KEY</b>:生成密钥对后的商户公钥,发给客服录入;本地不参与验签逻辑,仅作备案。</li>
+ * <li><b>PLATFORM_PUBLIC_KEY</b>:客服提供的「平台公钥」,用于验签接口返回的 {@code data} 与异步通知。</li>
+ * </ul>
+ */
+public final class PayV2GatewayKeys {
+
+ private PayV2GatewayKeys() {
+ }
+
+ /**
+ * 商户 PKCS#8 私钥 PEM(-----BEGIN PRIVATE KEY----- …),或 PKCS#1(-----BEGIN RSA PRIVATE KEY----- …)。<br>
+ * 切勿把公钥填在此字段;公钥与私钥 Base64 形态不同,误填会报 DER/InvalidKeySpec。
+ */
+ public static final String MERCHANT_PRIVATE_KEY_PEM = "MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCjjkfCt8M+nWN53UGOr9M4U36/6p0TtJQToen/kUc9/Myp5xuo3oKDEJ+gpSqdsFrK8UraPbciUf/nTiyYdXxqoeVK0qCSpbNWCr83vLzJxCLTGQIUn8c169DCZMIqI3EC262V7UAjA2pxWdQkw/Glw4UhGNNPT52zvHIjzx7KHCUDzvTUGrZfE1QPxe2JiVkPSap9qo4F6wp7I6Eu2PS7W1Q1JH/LRfsmJTHFDkwJquDieL5aKV3VEv/ZmsxKwmA1+AxdgLHjh6jCWBqJCQkDDIo9N2ymK2UfHWk/Hov87/fFRSGO9fjzUx9LAI8WEHFQBtJ8Ps45j4ZHIxEDi9JxAgMBAAECggEAGb6vtQrQBO8pVWlqhFdQ3DDoZrU7fHqURHLcLopjuHIulR/3zqEX0iGvvs3A44l7AS7yJWBJj3Fw4qv/gRAdQXuyaBC3jd3fWM/gQZnH7cqd4LSzCyQLa4VvGupZVeC2TUjfDhaGXfyPqMAHELJ3OyyHrCoIORfDLyOeo9xacF21RvRpvIsRjbqqnbH0BEdf3HL3aIwayjrtI/6UAlGoxni8Ky2mFfy4mi8NLl52KhQxPUdilf04lnlo3DymttjGtWFHhxErELeKWaV6TI+mcGTAz7IYf+8/hoNyypHLS64N7k1oQUIeSDWLi7HsoJHWSxcZtpryufwVkvgTRupnQQKBgQDW2zqKmWFfpZa5BDvH62zHc3EdWRSKE4RKlIc96G/qEQ8zCKO90Ya5dE0H9Ig5y/18JHLYjqVdhWQaCIMlH7j0M8PIgVwDFFBI+vRm8C95yEVwoFr/QQxKDCoD7hzSMIiZqgHsHkQLL80i2iHx21oAv0pCgDJUGYYyCHO8Bp4rJQKBgQDC4CzEVjMumjad0ML7eX+hS/fSo6ezYuxfpGjFUyyDdyOp9fZac/hEzI4HiJEThJeSkL5VCPbiV2BMNzJhf2xZSym4fFZC9nW8EHOVxHoxOeeHfG3X2qfOAINk9/P7LSbn4ATO1ckRikC0119AWzW5M/11720G9OLs1gU8/ceuXQKBgFdufOMbyWkvrCb8FwuivPBpBNXJgzcw+/uRd3t0093vNT0yPdenpOhg8FvVYX4Licpz1pxTZ+2ns3V4k02PHCebzQcRxQynvogEP2KISPmIyuErD/yhTsvvKUXSJr5N67iEWyXnpw7aU+Lj2z5dYcG+Fgz7t/9LJ7XChL41/zVVAoGAW34GZpOV6g5LECMAODLd4iuZiZJ+XLFYCrtU2TfokTxxSQ2KgQMrj5l+ITlT65b31r8QLTpNNw0Q0BemFrJNe0rXpp7xnPS7Z/VNXwZk3BG0ix63L32gBQ6modPr/4Q+XOUHPNiQUyTWplDrjnqEKZSoLiOfy4FTvR/qS61Wf50CgYBe69jJ8Ukpn0Tp45K9qI2JjKVczZuJMjJAi7ql3Puzq7GKEaDR0bzQWVDKoE7NER0Y6LFvLCvYPS0LgPR0UfVzLHrjnMPygVacvJgZSRVgDV5yQ/+DpFA2tVnCMcj7lit6EHPoqMNAdThiO4yjsi2tT2aNlWVliIa2sgGA7OSGfg==";
+
+ /** 平台公钥:PEM 或文档提供的纯 Base64 */
+ public static final String PLATFORM_PUBLIC_KEY = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoHiKCik6tA+56r7UEMWKX/WRlCx9EDxfUzOu8Of3GehVEQXIVRGAlfuMx78BOLVGgn3ou+3SSWCW7gyFXqN7/Y2MKb+df78X1XEt0iFrnUNN/ad2jGJVWSNIPljKKtm+v4uI3hFU2gavXtYwAODinXtCuNiAjEOaTa5sFYJt55soKXmFtV7CtRxm9WOLTcJQzF3mzR2r+lpH0TrTBbT8OLCcG+nXX+2T2gVTdMbzxnn689L5XrFF6QlcA9QJjtLBAbeju2zeqZHMMt9HcOy3UDfSGRrVUtEg/xKppf+88IGrE3CEhYX8a2IUVO/Tq9LE6MOCILcbeXvfKvrxb+muHwIDAQAB";
+}
diff --git a/src/main/java/com/nq/utils/pay/PayV2RsaSignUtil.java b/src/main/java/com/nq/utils/pay/PayV2RsaSignUtil.java
new file mode 100644
index 0000000..7c16aef
--- /dev/null
+++ b/src/main/java/com/nq/utils/pay/PayV2RsaSignUtil.java
@@ -0,0 +1,175 @@
+package com.nq.utils.pay;
+
+import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.bouncycastle.openssl.PEMKeyPair;
+import org.bouncycastle.openssl.PEMParser;
+import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter;
+
+import java.io.StringReader;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyFactory;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.security.Security;
+import java.security.Signature;
+import java.security.spec.InvalidKeySpecException;
+import java.security.spec.PKCS8EncodedKeySpec;
+import java.security.spec.X509EncodedKeySpec;
+import java.util.Base64;
+import java.util.Map;
+import java.util.TreeMap;
+
+/**
+ * Pay v2 网关数字签名(与官方文档一致)。
+ * <p>
+ * <b>数据拼接 stringA:</b>集合 M 内参数按参数名 ASCII 字典序排序;格式 {@code key1=value1&key2=value2};
+ * 值为空不参与;不做 URL Encode;{@code sign} 不参与拼接。
+ * <p>
+ * <b>数据签名:</b>对 stringA 使用<b>商户 PKCS8 私钥</b>做 {@code SHA256withRSA},再 Base64 得到 {@code sign}。
+ * <p>
+ * <b>数据验签:</b>用<b>平台公钥</b>校验。若报文含 {@code data} 对象,则对 {@code data} 内除 {@code sign} 外的字段拼 stringA 再验签(一般为接口响应);
+ * 否则对顶层除 {@code sign} 外的字段验签(一般为异步通知)。
+ * <p>
+ * 密钥:2048 位、PKCS8 生成可参考 https://uutool.cn/rsa-generate/ ;商户公钥发给客服录入;私钥配置在 {@link com.nq.common.PayV2GatewayKeys#MERCHANT_PRIVATE_KEY_PEM}。
+ */
+public final class PayV2RsaSignUtil {
+
+ private static final String RSA = "RSA";
+ private static final String SIGN_ALG = "SHA256withRSA";
+
+ static {
+ if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
+ Security.addProvider(new BouncyCastleProvider());
+ }
+ }
+
+ private PayV2RsaSignUtil() {
+ }
+
+ public static String buildStringA(Map<String, String> params) {
+ Map<String, String> sorted = new TreeMap<>();
+ for (Map.Entry<String, String> e : params.entrySet()) {
+ String k = e.getKey();
+ if (k == null || "sign".equalsIgnoreCase(k)) {
+ continue;
+ }
+ String v = e.getValue();
+ if (v == null || v.isEmpty()) {
+ continue;
+ }
+ sorted.put(k, v);
+ }
+ StringBuilder sb = new StringBuilder();
+ for (Map.Entry<String, String> e : sorted.entrySet()) {
+ sb.append(e.getKey()).append("=").append(e.getValue()).append("&");
+ }
+ if (sb.length() > 0) {
+ sb.setLength(sb.length() - 1);
+ }
+ return sb.toString();
+ }
+
+ public static String sign(String stringA, String merchantPrivateKeyPemOrBase64) throws Exception {
+ PrivateKey privateKey = parsePrivateKey(merchantPrivateKeyPemOrBase64);
+ Signature signature = Signature.getInstance(SIGN_ALG);
+ signature.initSign(privateKey);
+ signature.update(stringA.getBytes(StandardCharsets.UTF_8));
+ return Base64.getEncoder().encodeToString(signature.sign());
+ }
+
+ public static String sign(Map<String, String> params, String merchantPrivateKeyPemOrBase64) throws Exception {
+ return sign(buildStringA(params), merchantPrivateKeyPemOrBase64);
+ }
+
+ public static boolean verify(String stringA, String signBase64, String platformPublicKeyPemOrBase64) {
+ if (signBase64 == null || signBase64.isEmpty()) {
+ return false;
+ }
+ try {
+ PublicKey publicKey = parsePublicKey(platformPublicKeyPemOrBase64);
+ Signature signature = Signature.getInstance(SIGN_ALG);
+ signature.initVerify(publicKey);
+ signature.update(stringA.getBytes(StandardCharsets.UTF_8));
+ byte[] sig = Base64.getDecoder().decode(signBase64.trim().replace("\n", "").replace("\r", ""));
+ return signature.verify(sig);
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ public static boolean verify(Map<String, String> params, String signBase64, String platformPublicKeyPemOrBase64) {
+ return verify(buildStringA(params), signBase64, platformPublicKeyPemOrBase64);
+ }
+
+ /**
+ * 解析商户 RSA 私钥:支持 PKCS#8 PEM、PKCS#1 PEM(RSA PRIVATE KEY)、或裸 PKCS#8 Base64。
+ */
+ public static PrivateKey parsePrivateKey(String pemOrBase64) throws Exception {
+ String s = pemOrBase64 == null ? "" : pemOrBase64.trim();
+ if (s.isEmpty() || s.contains("REPLACE_WITH")) {
+ throw new IllegalArgumentException(
+ "PayV2GatewayKeys.MERCHANT_PRIVATE_KEY_PEM 未配置有效私钥:请粘贴完整 PEM(推荐 PKCS#8:-----BEGIN PRIVATE KEY-----),勿填公钥或占位符。");
+ }
+ if (looksLikeRsaPublicKeySpkiBase64(s)) {
+ throw new IllegalArgumentException(
+ "MERCHANT_PRIVATE_KEY_PEM 内容疑似「公钥」(X.509 SPKI)。签名必须使用「私钥」;公钥应只填在 MERCHANT_PUBLIC_KEY 并发给支付平台。");
+ }
+ if (s.contains("BEGIN")) {
+ try (PEMParser pemParser = new PEMParser(new StringReader(s))) {
+ Object obj = pemParser.readObject();
+ JcaPEMKeyConverter converter = new JcaPEMKeyConverter().setProvider(BouncyCastleProvider.PROVIDER_NAME);
+ if (obj instanceof PEMKeyPair) {
+ return converter.getKeyPair((PEMKeyPair) obj).getPrivate();
+ }
+ if (obj instanceof PrivateKeyInfo) {
+ return converter.getPrivateKey((PrivateKeyInfo) obj);
+ }
+ }
+ throw new IllegalArgumentException(
+ "PEM 中未解析出 RSA 私钥。请确认含 -----BEGIN PRIVATE KEY----- 或 -----BEGIN RSA PRIVATE KEY----- 完整块。");
+ }
+ byte[] keyBytes = Base64.getDecoder().decode(s.replaceAll("\\s+", ""));
+ try {
+ return KeyFactory.getInstance(RSA).generatePrivate(new PKCS8EncodedKeySpec(keyBytes));
+ } catch (InvalidKeySpecException e) {
+ throw new IllegalArgumentException(
+ "裸 Base64 不是 PKCS#8 私钥 DER。请粘贴完整 PEM,或用 openssl:openssl pkcs8 -topk8 -nocrypt -in rsa.key -out pkcs8.pem", e);
+ }
+ }
+
+ /**
+ * 无 PEM 头时,常见 2048 位 RSA 公钥 SPKI 的 Base64 固定前缀(与 PKCS#8 私钥不同)。
+ */
+ private static boolean looksLikeRsaPublicKeySpkiBase64(String s) {
+ if (s.contains("BEGIN")) {
+ return false;
+ }
+ String compact = s.replaceAll("\\s+", "");
+ return compact.startsWith("MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA");
+ }
+
+ public static PublicKey parsePublicKey(String pemOrBase64) throws Exception {
+ String s = pemOrBase64.trim();
+ byte[] keyBytes;
+ if (s.contains("BEGIN PUBLIC KEY")) {
+ keyBytes = Base64.getDecoder().decode(extractPemBody(s, "-----BEGIN PUBLIC KEY-----", "-----END PUBLIC KEY-----"));
+ } else {
+ keyBytes = Base64.getDecoder().decode(s.replaceAll("\\s+", ""));
+ }
+ return KeyFactory.getInstance(RSA).generatePublic(new X509EncodedKeySpec(keyBytes));
+ }
+
+ private static String extractPemBody(String pem, String begin, String end) {
+ int a = pem.indexOf(begin);
+ if (a < 0) {
+ return pem.replaceAll("\\s+", "");
+ }
+ a += begin.length();
+ int b = pem.indexOf(end, a);
+ if (b < 0) {
+ return pem.substring(a).replaceAll("\\s+", "");
+ }
+ return pem.substring(a, b).replaceAll("\\s+", "");
+ }
+}
--
Gitblit v1.9.3