1
zj
2026-03-24 a37fcac56e57dbb27c204a152e95c7762c426bd4
1
2 files added
199 ■■■■■ changed files
src/main/java/com/nq/common/PayV2GatewayKeys.java 24 ●●●●● patch | view | raw | blame | history
src/main/java/com/nq/utils/pay/PayV2RsaSignUtil.java 175 ●●●●● patch | view | raw | blame | history
src/main/java/com/nq/common/PayV2GatewayKeys.java
New file
@@ -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";
}
src/main/java/com/nq/utils/pay/PayV2RsaSignUtil.java
New file
@@ -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+", "");
    }
}