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 网关数字签名(与官方文档一致)。 *

* 数据拼接 stringA:集合 M 内参数按参数名 ASCII 字典序排序;格式 {@code key1=value1&key2=value2}; * 值为空不参与;不做 URL Encode;{@code sign} 不参与拼接。 *

* 数据签名:对 stringA 使用商户 PKCS8 私钥做 {@code SHA256withRSA},再 Base64 得到 {@code sign}。 *

* 数据验签:平台公钥校验。若报文含 {@code data} 对象,则对 {@code data} 内除 {@code sign} 外的字段拼 stringA 再验签(一般为接口响应); * 否则对顶层除 {@code sign} 外的字段验签(一般为异步通知)。 *

* 密钥: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 params) { Map sorted = new TreeMap<>(); for (Map.Entry 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 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 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 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+", ""); } }