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