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+", "");
|
}
|
}
|