Java 签名与验签示例
运行环境
Java 17+,仅使用 JDK 标准库,不依赖 Lombok 或第三方加密库。
统一规则
这些示例用于默认商户 API 和默认平台回调,不适用于另行约定的定制签名格式。中英文页面引用同一份可执行代码。
有请求体的 API 请求,使用商户私钥签名:
path + lowercaseHex(SHA512(rawBody)) + Timestamp
无请求体的 API 请求,使用商户私钥签名:
path + Timestamp
默认异步回调,使用平台公钥验签:
lowercaseHex(SHA512(rawBody)) + Timestamp
API 同步响应封装,使用平台公钥验签:
lowercaseHex(SHA512(平台序列化的 data 字节)) + timestamp
- 算法统一为 RSA PKCS#1 v1.5 + SHA-512,签名使用标准 Base64,不是 RSA-PSS,也不是对原文做 RSA 加密。
Timestamp使用秒级 Unix 时间戳;验签使用收到的时间戳,不要重新生成。Header 名称不区分大小写,但 Base64 签名值区分大小写。- 请求
path是接口路径,不包含协议、域名或查询字符串。回调原文不拼路径、URL 或分隔符。 - JSON 只序列化一次:签名和发送必须使用同一份 UTF-8 字节。字段顺序不要求预先排序,但空格、转义、换行、数字表示和顺序必须与发送内容完全一致。
- 无请求体使用
null/nil/None;它与“存在但长度为零的请求体”不同。回调始终对收到的完整 body 求摘要。 - 私钥支持未加密 PKCS#1、PKCS#8;公钥支持 X.509 SPKI、PKCS#1;支持 PEM、纯 Base64 和转义换行。证书、加密私钥、SSH 格式及非 RSA 密钥不在本示例支持范围内。
- 商户请求用商户私钥;验签必须使用后台配置的 平台公钥。不能使用商户自己的公钥,也不能信任请求中携带的公钥。不要发送私钥给技术支持;泄露后应更换。
失败处理
签名失败抛出异常或返回 error,调用方必须停止发送,不能用空签名继续请求。验签成功正常返回,失败记录 [payment-verify] FAILED 并抛出异常或返回 error;不得忽略失败后继续入账。
日志包含阶段、字节长度和错误原因,不记录原始请求体、完整签名或密钥。生产环境不要打开 HTTP 正文日志;不要把内部诊断错误原样返回给调用方,并对失败请求和日志做限流。
HTTPS 必须校验证书与主机名。网络超时不能证明订单创建失败,不能盲目自动重新下单;应按商户订单号查询或人工核查。
回调接入
先限制请求体大小,读取原始字节,读取 Timestamp、Signature,验签成功后再解析 JSON。检查订单归属、金额、币种和允许的状态变化,通过事务做持久化幂等处理。只有完成可靠处理后才按协议返回 SUCCESS。重复通知不得重复产生资金变动。
验签函数只负责密码学校验,不等于防重放。时间窗口应结合平台重试规则设置;重放检测、持久化幂等、回调请求限流仍由接入方实现。不要因固定短时间窗口而拒绝正常重试。
同步响应不能当作回调
API 同步响应从 JSON 的 timestamp、sign 字段取值,验签对象是 data 的平台序列化字节,不是整个响应体;回调则从 Header 取值,验签对象是完整 HTTP body。
不要把 data 解析后重新序列化再验签:大整数、1.00、转义或字段顺序可能改变。使用能保留 JSON 原始值文本的解析器提取 data(例如 Go json.RawMessage、C# JsonElement.GetRawText()),并把这些 UTF-8 字节传给示例的 verifyPlatform / VerifyPlatform / verify_platform。data 是字符串时保留 JSON 引号与转义,是 null 时保留字面量 null;不要自行添加换行或空格。缺少签名、字段异常或验签失败必须拒绝作为可信业务结果。
接口路径
同一请求函数可用于以下路径,参数以相应币种文档为准:
| 方法 | 路径 | 用途 |
|---|---|---|
| POST | /v1/merchant/pay | 代收下单 |
| POST | /v1/merchant/pay/order/info | 代收查单 |
| POST | /v1/merchant/payout | 代付下单 |
| POST | /v1/merchant/payout/order/info | 代付查单 |
| GET | /v1/merchant/account | 余额查询,无请求体 |
签名与验签完整代码
SignUtil.java
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.security.interfaces.RSAKey;
import java.security.spec.*;
import java.util.Base64;
import java.util.HexFormat;
import java.util.regex.Pattern;
public final class SignUtil {
private SignUtil() {}
private static final byte[] RSA_ALGORITHM = HexFormat.of().parseHex("300d06092a864886f70d0101010500");
private static String timestamp(String text) {
try {
if (text == null || !text.matches("[1-9][0-9]{0,18}") || Long.parseLong(text) <= 0) throw new IllegalArgumentException();
return text;
} catch (IllegalArgumentException e) { throw new IllegalArgumentException("invalid Timestamp: use Unix seconds"); }
}
private static byte[] base64(String text, String label) {
try {
byte[] bytes = Base64.getDecoder().decode(text);
if (bytes.length == 0 || !Base64.getEncoder().encodeToString(bytes).equals(text)) throw new IllegalArgumentException();
return bytes;
} catch (IllegalArgumentException | NullPointerException e) { throw new IllegalArgumentException(label + ": invalid Base64"); }
}
private static byte[] keyDER(String input, boolean privateKey) {
if (input == null || input.length() > 16384) throw new IllegalArgumentException("key: missing or too large");
String text = input.replace("\\r\\n", "\n").replace("\\n", "\n").trim();
if (text.startsWith("-----BEGIN")) {
var m = Pattern.compile("\\A-----BEGIN ([A-Z ]+)-----\\s*([A-Za-z0-9+/=\\s]+)\\s*-----END \\1-----\\z").matcher(text);
if (!m.matches()) throw new IllegalArgumentException("key: expected one complete PEM block");
String label = m.group(1);
boolean allowed = privateKey ? label.equals("PRIVATE KEY") || label.equals("RSA PRIVATE KEY")
: label.equals("PUBLIC KEY") || label.equals("RSA PUBLIC KEY");
if (!allowed) throw new IllegalArgumentException("key: expected unencrypted RSA PEM of the correct kind");
text = m.group(2);
}
return base64(text.replaceAll("\\s", ""), "key");
}
// Container wrapping only; JCA performs ASN.1 parsing and RSA key import.
private static byte[] der(int tag, byte[]... values) {
var data = new ByteArrayOutputStream();
for (byte[] value : values) data.writeBytes(value);
byte[] bytes = data.toByteArray();
var out = new ByteArrayOutputStream();
out.write(tag);
if (bytes.length < 128) out.write(bytes.length);
else {
int count = (32 - Integer.numberOfLeadingZeros(bytes.length) + 7) / 8;
out.write(128 | count);
for (int i = count - 1; i >= 0; i--) out.write(bytes.length >>> (i * 8));
}
out.writeBytes(bytes);
return out.toByteArray();
}
private static Key key(String input, boolean privateKey) throws GeneralSecurityException {
byte[] raw = keyDER(input, privateKey);
byte[] wrapped = privateKey
? der(48, new byte[]{2, 1, 0}, RSA_ALGORITHM, der(4, raw))
: der(48, RSA_ALGORITHM, der(3, new byte[]{0}, raw));
for (byte[] bytes : new byte[][]{raw, wrapped}) {
try {
var factory = KeyFactory.getInstance("RSA");
return privateKey ? factory.generatePrivate(new PKCS8EncodedKeySpec(bytes))
: factory.generatePublic(new X509EncodedKeySpec(bytes));
} catch (InvalidKeySpecException e) { /* Try the other documented container. */ }
}
throw new InvalidKeySpecException("key: invalid/truncated, encrypted or non-RSA key; DER bytes=" + raw.length);
}
private static String hash(byte[] body) throws GeneralSecurityException {
if (body == null) throw new IllegalArgumentException("body: expected raw bytes");
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-512").digest(body));
}
private static void log(String operation, byte[] body, Exception e) {
String reason = e instanceof IllegalArgumentException || e instanceof InvalidKeySpecException
? e.getMessage() : "RSA operation failed (" + e.getClass().getSimpleName() + ")";
System.err.printf("[payment-%s] FAILED body_bytes=%d error=%s%n", operation, body == null ? 0 : body.length, reason);
}
// null means no request body; new byte[0] means a present, empty body.
public static String signRequest(String path, byte[] body, String timestamp, String merchantPrivateKey) throws GeneralSecurityException {
try {
if (path == null || !path.startsWith("/")) throw new IllegalArgumentException("path: expected API path");
String origin = path + (body == null ? "" : hash(body)) + timestamp(timestamp);
var signer = Signature.getInstance("SHA512withRSA");
signer.initSign((PrivateKey) key(merchantPrivateKey, true));
signer.update(origin.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(signer.sign());
} catch (GeneralSecurityException | RuntimeException e) { log("sign", body, e); throw e; }
}
// Exact callback bytes, or exact signed data bytes for API envelopes. NO path.
public static void verifyPlatform(byte[] body, String timestamp, String signature, String platformPublicKey) throws GeneralSecurityException {
try {
String origin = hash(body) + timestamp(timestamp);
PublicKey publicKey = (PublicKey) key(platformPublicKey, false);
byte[] sig = base64(signature, "Signature");
int expected = (((RSAKey) publicKey).getModulus().bitLength() + 7) / 8;
if (sig.length != expected) throw new IllegalArgumentException("Signature length: got " + sig.length + ", expected " + expected + " bytes");
var verifier = Signature.getInstance("SHA512withRSA");
verifier.initVerify(publicKey);
verifier.update(origin.getBytes(StandardCharsets.UTF_8));
if (!verifier.verify(sig)) throw new IllegalArgumentException("RSA SHA512 verification failed: check platform key, raw bytes, timestamp and formula");
} catch (GeneralSecurityException | RuntimeException e) { log("verify", body, e); throw e; }
}
}
HTTP 请求工具
HttpUtil.java
import java.io.IOException;
import java.net.URI;
import java.net.http.*;
import java.time.Duration;
import java.time.Instant;
import java.security.GeneralSecurityException;
public final class HttpUtil {
private static final HttpClient CLIENT = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10))
.followRedirects(HttpClient.Redirect.NEVER).build();
public static byte[] send(String baseUrl, String path, String merchantCode, byte[] rawBody, String merchantPrivateKey)
throws GeneralSecurityException, IOException, InterruptedException {
if (!baseUrl.startsWith("https://")) throw new IllegalArgumentException("HTTPS is required");
String timestamp = Long.toString(Instant.now().getEpochSecond());
String signature = SignUtil.signRequest(path, rawBody, timestamp, merchantPrivateKey);
var builder = HttpRequest.newBuilder(URI.create(baseUrl + path)).timeout(Duration.ofSeconds(60))
.header("Content-Type", "application/json").header("Merchant-Code", merchantCode)
.header("Timestamp", timestamp).header("Signature", signature);
if (rawBody == null) builder.GET();
else builder.POST(HttpRequest.BodyPublishers.ofByteArray(rawBody));
var response = CLIENT.send(builder.build(), HttpResponse.BodyHandlers.ofInputStream());
try (var stream = response.body()) {
if (response.statusCode() < 200 || response.statusCode() >= 300)
throw new IOException("HTTP " + response.statusCode() + "; reconcile before retrying");
byte[] result = stream.readNBytes((8 << 20) + 1);
if (result.length > (8 << 20)) throw new IOException("Response exceeds 8 MiB");
return result; // Preserve raw response for envelope verification.
}
}
}
接入示例
以下片段放在接入方应用中使用,payload、可信配置和事务业务处理函数由接入方提供;示例不会自动发起真实下单。
// Java 17+, imports java.nio.charset.StandardCharsets.
// Serialize payload once with your JSON library, without changing the bytes after signing.
byte[] body = serializedJson.getBytes(StandardCharsets.UTF_8);
byte[] responseBytes = HttpUtil.send(baseUrl, "/v1/merchant/pay", merchantCode, body, merchantPrivateKey);
// GET: HttpUtil.send(baseUrl, "/v1/merchant/account", merchantCode, null, merchantPrivateKey);
// Verify the signed response data before using it. Do not treat HTTP 200 as payment success.
// Inside your servlet handler. Also enforce an ingress/request body limit.
byte[] rawBody = request.getInputStream().readNBytes((1 << 20) + 1);
if (rawBody.length > (1 << 20)) { response.sendError(413); return; }
try {
SignUtil.verifyPlatform(rawBody, request.getHeader("Timestamp"),
request.getHeader("Signature"), configuredPlatformPublicKey);
} catch (java.security.GeneralSecurityException | IllegalArgumentException e) {
response.sendError(401, "invalid signature"); return;
}
// Only now parse rawBody, validate order/amount/currency and commit an idempotent update.
// Write SUCCESS only after durable processing.
