签名规则
大约 9 分钟
概述
数字签名用于保证 API 请求(收款、付款、查询等)的完整性与不可否认性。对接时请严格按本文规则生成 sign,否则接口会返回签名校验失败。
对接前准备
| 步骤 | 说明 |
|---|---|
| 1. 生成密钥对 | 使用 RSA PKCS#8(推荐 2048 位),妥善保管商户私钥 |
| 2. 配置公钥 | 在 TOPPAY 商户后台上传商户公钥(仅公钥上传至平台) |
| 3. 区分两种签名 | 请求签名:您用私钥生成;回调验签:使用平台公钥验证回调中的 sign |
| 4. 回调公钥 | 平台公钥在 后台 → API 配置 中查看,用于验证异步通知 |
签名规则(技术摘要)
| 项目 | 说明 |
|---|---|
| 算法 | RSA(发起方使用私钥加密生成签名,接收方使用公钥解密比对;请严格按本文示例代码实现,勿与标准 RSA-SHA256 Sign/Verify 混淆) |
| 编码 | 签名字符串输出为 Base64 |
| 私钥格式 | PKCS#8(推荐 2048 位;历史商户可能使用 1024 位) |
核心原则(务必遵守)
- 仅参数值参与拼接:参数名(Key)只用于排序,不参与签名字符串。
- 对所有非空参数的 Key 按 ASCII 升序排序。
- 按排序后的 Key 顺序依次取出参数值,直接首尾拼接得到待签名字符串,再使用 RSA 私钥加密得到
sign。
示例:从参数到签名
示例参数说明
下列参数与「收款」类接口字段一致,便于您对照文档;是否必填以具体接口为准。
| 参数 | 类型 | 必填 | 说明 | 示例值 |
|---|---|---|---|---|
| mchNo | String(32) | 是 | 商户号,商户平台 - 个人中心 | PHOT000001 |
| method | String(16) | 否 | 支付方式 | GCASH |
| orderNum | String(64) | 是 | 商户订单号 | T1642592278863 |
| amount | int(10) | 是 | 金额(与接口约定单位一致) | 10000 |
| productDetail | String(100) | 是 | 商品/订单描述 | Test Pay |
| downNotifyUrl | String(164) | 是 | 异步通知地址 | your notify url |
| timestamp | String(32) | 是 | 请求时间戳(13 位毫秒级 Unix 时间戳) | 1749451858772 |
| customerName | String(64) | 是 | 客户姓名 | JackMa |
| expiryPeriod | int(5) | 是 | 订单有效时间(分钟) | 1440 |
| customerEmail | String(64) | 是 | 客户邮箱 | [email protected] |
原始 JSON 示例(生成签名时不要包含 sign 字段):
{
"mchNo": "PHOT000001",
"method": "GCASH",
"orderNum": "T1642592278863",
"amount": 10000,
"productDetail": "Test Pay",
"downNotifyUrl": "your notify url",
"timestamp": "1749451858772",
"customerName": "JackMa",
"expiryPeriod": 1440,
"customerEmail": "[email protected]"
}
参数值与类型
- 拼接时使用的是最终参与传输的值的字符串形式(与 JSON 序列化后一致)。例如数字
10000、1440在拼接中为"10000"、"1440"对应的字符序列。 - 生成签名前请排除
sign字段;空字符串、null、缺省字段不参与签名。
步骤 1:参数 Key 按 ASCII 升序排序
将所有非空参数的 Key 按 ASCII 码升序排列:
| 排序 | 参数名 | 参数值 |
|---|---|---|
| 1 | amount | 10000 |
| 2 | customerEmail | [email protected] |
| 3 | customerName | JackMa |
| 4 | downNotifyUrl | your notify url |
| 5 | expiryPeriod | 1440 |
| 6 | mchNo | PHOT000001 |
| 7 | method | GCASH |
| 8 | orderNum | T1642592278863 |
| 9 | productDetail | Test Pay |
| 10 | timestamp | 1749451858772 |
步骤 2:拼接待签名字符串 StrA
按上表顺序只取参数值,无分隔符直接拼接:
StrA = [email protected] notify url1440PHOT000001GCASHT1642592278863Test Pay1749451858772
注意事项
- 只拼接参数值,不要拼接参数名。
- 空值不参与签名(不要出现在排序与拼接中)。
- 参数值之间无任何分隔符(无
&、=、换行等)。
步骤 3:计算签名
使用您在 TOPPAY 商户后台配置的 商户私钥(privateKey) 对 StrA 做 RSA 加密,再 Base64 编码:
sign = RSA(StrA, privateKey)
将得到的 sign 放入请求体(字段名以接口文档为准,一般为 sign)。
签名结果示例
IMLn23c4orM+7pZhHoRmbjrol4X33jeAqFxbZuQ+pnznBIGhb6Ail3qQPmKwcuhNCt536nmldpbWI72
k1lDxd0zZ95ZHElcNzwTFHFKtd8063uy6rFaxaW6DQ47t4U/95dpGfHAZe0GiIFAQ6xQquaoLINyQa4QqL+cpB
JFEg1dyW6GYLFSdJnx7ycQvFYllmOpGZmdPLny62GvrCWvkiIARUsmc9fpkpTx5UQEDTgmhwdCKBkhHVsx2AiQ
bYDxZ5WBuU1GZeiJjPuzSxvzWP6VoQBsfpwTI5kdJs6aQCekGO2/YScD+tGgrm2J89Pc/axPcb1xZzsi5SxpWh
feabQ==
请求 JSON 示例
{
"mchNo": "PHOT000001",
"method": "GCASH",
"orderNum": "T1642592278863",
"amount": 10000,
"productDetail": "Test Pay",
"downNotifyUrl": "your notify url",
"timestamp": "1749451858772",
"customerName": "JackMa",
"expiryPeriod": 1440,
"customerEmail": "[email protected]",
"sign": "IMLn23c4orM+7pZhHoRmbjrol4X33jeAqFxbZuQ+pnznBIGhb6Ail3qQPmKwcuhNCt536nmldpbWI72k1lDxd0zZ95ZHElcNzwTFHFKtd8063uy6rFaxaW6DQ47t4U/95dpGfHAZe0GiIFAQ6xQquaoLINyQa4QqL+cpBJFEg1dyW6GYLFSdJnx7ycQvFYllmOpGZmdPLny62GvrCWvkiIARUsmc9fpkpTx5UQEDTgmhwdCKBkhHVsx2AiQbYDxZ5WBuU1GZeiJjPuzSxvzWP6VoQBsfpwTI5kdJs6aQCekGO2/YScD+tGgrm2J89Pc/axPcb1xZzsi5SxpWhfeabQ=="
}
请求签名与回调验签
| 类型 | 谁持有私钥 | 谁持有公钥 | 您的操作 |
|---|---|---|---|
| 请求签名 | 商户 | 平台保存商户公钥 | 用商户私钥生成 sign 发起请求 |
| 回调验签 | 平台 | 商户保存平台公钥 | 从回调参数取出 sign,按同样规则拼接其余参数后,用平台公钥解密比对 |
回调验签时:先取出 sign,其余非空参数按 Key ASCII 排序后拼接参数值,与公钥解密结果一致即为验签通过(详见下方代码中的 verifySign 逻辑)。
代码示例
示例说明
下方 Java 示例中的 DefaultHttpClient(Apache HttpClient 4.x)已废弃,仅作签名逻辑参考;生产环境请使用 HttpClient 5.x 或您项目中的 HTTP 客户端。
import com.google.gson.JsonObject;
import org.apache.commons.codec.binary.Base64;
import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.util.EntityUtils;
import org.apache.tomcat.util.http.fileupload.IOUtils;
import javax.crypto.Cipher;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* TOPPAY RSA签名工具类
*
* @author TOPPAY
*/
public class TOPPAYRequestUtil {
/**
* 生成签名
* @param params 请求参数
* @param privateKey 商户私钥
* @return 签名字符串
*/
public static String generateSign(JsonObject params, String privateKey)
throws InvalidKeySpecException, NoSuchAlgorithmException {
// 1. 获取所有参数名并按ASCII排序
List<String> paramNameList = new ArrayList<>(params.keySet());
Collections.sort(paramNameList);
// 2. 按排序后的顺序拼接参数值
StringBuilder stringBuilder = new StringBuilder();
for (String name : paramNameList) {
if (params.get(name) != null && !params.get(name).isJsonNull()) {
String value = params.get(name).getAsString();
if (value != null && !value.isEmpty()) {
stringBuilder.append(value);
}
}
}
String strA = stringBuilder.toString();
System.out.println("待签名字符串: " + strA);
// 3. 使用私钥进行RSA加密
String sign = privateEncrypt(strA, getPrivateKey(privateKey));
return sign;
}
/**
* 验证签名
* @param params 响应参数
* @param publicKey 平台公钥
* @return 验证结果
*/
public static boolean verifySign(JsonObject params, String publicKey)
throws InvalidKeySpecException, NoSuchAlgorithmException {
String sign = params.remove("sign").getAsString();
List<String> paramNameList = new ArrayList<>(params.keySet());
Collections.sort(paramNameList);
StringBuilder stringBuilder = new StringBuilder();
for (String name : paramNameList) {
if (params.get(name) != null && !params.get(name).isJsonNull()) {
String value = params.get(name).getAsString();
if (value != null && !value.isEmpty()) {
stringBuilder.append(value);
}
}
}
System.out.println("验签字符串: " + stringBuilder);
String decryptSign = publicDecrypt(sign, getPublicKey(publicKey));
System.out.println("解密签名: " + decryptSign);
return stringBuilder.toString().equals(decryptSign);
}
/**
* 私钥加密
* @param data 待加密数据
* @param privateKey RSA私钥
* @return Base64编码的加密结果
*/
public static String privateEncrypt(String data, RSAPrivateKey privateKey) {
try {
Cipher cipher = Cipher.getInstance("RSA");
cipher.init(Cipher.ENCRYPT_MODE, privateKey);
return Base64.encodeBase64String(rsaSplitCodec(cipher, Cipher.ENCRYPT_MODE,
data.getBytes("UTF-8"), privateKey.getModulus().bitLength()));
} catch (Exception e) {
throw new RuntimeException("加密字符串[" + data + "]时遇到异常", e);
}
}
/**
* 公钥解密
* @param data Base64编码的加密数据
* @param publicKey RSA公钥
* @return 解密后的字符串
*/
public static String publicDecrypt(String data, RSAPublicKey publicKey) {
try {
Cipher cipher = Cipher.getInstance("RSA");
cipher.init(Cipher.DECRYPT_MODE, publicKey);
return new String(rsaSplitCodec(cipher, Cipher.DECRYPT_MODE,
Base64.decodeBase64(data), publicKey.getModulus().bitLength()), "UTF-8");
} catch (Exception e) {
throw new RuntimeException("解密字符串[" + data + "]时遇到异常", e);
}
}
/**
* 获取RSA私钥对象
* @param privateKey Base64编码的私钥字符串
* @return RSA私钥对象
*/
public static RSAPrivateKey getPrivateKey(String privateKey)
throws NoSuchAlgorithmException, InvalidKeySpecException {
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
PKCS8EncodedKeySpec pkcs8KeySpec = new PKCS8EncodedKeySpec(Base64.decodeBase64(privateKey));
RSAPrivateKey key = (RSAPrivateKey) keyFactory.generatePrivate(pkcs8KeySpec);
return key;
}
/**
* 获取RSA公钥对象
* @param publicKey Base64编码的公钥字符串
* @return RSA公钥对象
*/
public static RSAPublicKey getPublicKey(String publicKey)
throws NoSuchAlgorithmException, InvalidKeySpecException {
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
X509EncodedKeySpec x509KeySpec = new X509EncodedKeySpec(Base64.decodeBase64(publicKey));
RSAPublicKey key = (RSAPublicKey) keyFactory.generatePublic(x509KeySpec);
return key;
}
/**
* RSA分段加解密
* @param cipher 加密器
* @param opmode 操作模式(加密/解密)
* @param datas 待处理数据
* @param keySize 密钥大小
* @return 处理结果
*/
private static byte[] rsaSplitCodec(Cipher cipher, int opmode, byte[] datas, int keySize) {
int maxBlock = 0;
if (opmode == Cipher.DECRYPT_MODE) {
maxBlock = keySize / 8;
} else {
maxBlock = keySize / 8 - 11;
}
ByteArrayOutputStream out = new ByteArrayOutputStream();
int offSet = 0;
byte[] buff;
int i = 0;
try {
while (datas.length > offSet) {
if (datas.length - offSet > maxBlock) {
buff = cipher.doFinal(datas, offSet, maxBlock);
} else {
buff = cipher.doFinal(datas, offSet, datas.length - offSet);
}
out.write(buff, 0, buff.length);
i++;
offSet = i * maxBlock;
}
} catch (Exception e) {
throw new RuntimeException("加解密阀值为[" + maxBlock + "]的数据时发生异常", e);
}
byte[] resultDatas = out.toByteArray();
IOUtils.closeQuietly(out);
return resultDatas;
}
/**
* 发送POST请求
* @param url 请求地址
* @param json JSON请求体
* @return 响应结果
*/
public static String doPost(String url, String json) throws IOException {
HttpClient client = new DefaultHttpClient();
HttpPost post = new HttpPost(url);
StringEntity s = new StringEntity(json);
s.setContentEncoding("UTF-8");
s.setContentType("application/json");
post.setEntity(s);
HttpResponse res = client.execute(post);
if (res.getStatusLine().getStatusCode() == HttpStatus.SC_OK) {
return EntityUtils.toString(res.getEntity());
}
return null;
}
}
@tab PHP
<?php
/**
* TOPPAY RSA签名工具类
*
* @author TOPPAY
*/
class TOPPAYRequestUtil
{
/**
* 生成签名
* @param array $params 请求参数
* @param string $privateKey 商户私钥
* @return string 签名字符串
*/
public static function generateSign(array $params, string $privateKey): string
{
// 1. 获取所有参数名并按ASCII排序
$keys = array_keys($params);
sort($keys, SORT_STRING);
// 2. 按排序后的顺序拼接参数值
$strA = '';
foreach ($keys as $key) {
$value = $params[$key];
if ($value !== null && $value !== '') {
$strA .= $value;
}
}
// 3. 使用私钥进行RSA加密
$privateKeyResource = openssl_pkey_get_private(
"-----BEGIN PRIVATE KEY-----\n" .
wordwrap($privateKey, 64, "\n", true) .
"\n-----END PRIVATE KEY-----"
);
$encrypted = '';
$data = $strA;
$keyDetails = openssl_pkey_get_details($privateKeyResource);
$maxBlockSize = $keyDetails['bits'] / 8 - 11;
$output = '';
while (strlen($data) > 0) {
$chunk = substr($data, 0, $maxBlockSize);
$data = substr($data, $maxBlockSize);
openssl_private_encrypt($chunk, $encrypted, $privateKeyResource);
$output .= $encrypted;
}
return base64_encode($output);
}
/**
* 验证签名
* @param array $params 响应参数
* @param string $publicKey 平台公钥
* @return bool 验证结果
*/
public static function verifySign(array $params, string $publicKey): bool
{
$sign = $params['sign'];
unset($params['sign']);
// 获取所有参数名并按ASCII排序
$keys = array_keys($params);
sort($keys, SORT_STRING);
// 按排序后的顺序拼接参数值
$strA = '';
foreach ($keys as $key) {
$value = $params[$key];
if ($value !== null && $value !== '') {
$strA .= $value;
}
}
// 使用公钥解密签名
$publicKeyResource = openssl_pkey_get_public(
"-----BEGIN PUBLIC KEY-----\n" .
wordwrap($publicKey, 64, "\n", true) .
"\n-----END PUBLIC KEY-----"
);
$encryptedData = base64_decode($sign);
$keyDetails = openssl_pkey_get_details($publicKeyResource);
$maxBlockSize = $keyDetails['bits'] / 8;
$decrypted = '';
while (strlen($encryptedData) > 0) {
$chunk = substr($encryptedData, 0, $maxBlockSize);
$encryptedData = substr($encryptedData, $maxBlockSize);
$decryptedChunk = '';
openssl_public_decrypt($chunk, $decryptedChunk, $publicKeyResource);
$decrypted .= $decryptedChunk;
}
return $strA === $decrypted;
}
/**
* 发送POST请求
* @param string $url 请求地址
* @param array $data 请求数据
* @return string|false 响应结果
*/
public static function doPost(string $url, array $data)
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Accept: application/json'
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200) {
return $response;
}
return false;
}
}
// 使用示例
$params = [
'mchNo' => 'PHOT000001',
'method' => 'GCASH',
'orderNum' => 'T1642592278863',
'amount' => 10000,
'productDetail' => 'Test Pay',
'downNotifyUrl' => 'your notify url',
'timestamp' => '1749451858772',
'customerName' => 'JackMa',
'expiryPeriod' => 1440,
'customerEmail' => '[email protected]'
];
$privateKey = 'YOUR_PRIVATE_KEY_HERE';
$sign = TOPPAYRequestUtil::generateSign($params, $privateKey);
$params['sign'] = $sign;
// 发送请求
$response = TOPPAYRequestUtil::doPost('https://gateway.TOPPAY.com/v2.0/transaction/pay-in', $params);
:::
安全与排错建议
实现注意
常见对接错误
- 签名包含
sign:计算签名前必须去掉sign。 - 顺序错误:必须按 Key 的 ASCII 排序,不是按业务含义或文档表格顺序。
- 多空格/换行:参数值首尾空格会参与拼接,导致与后台不一致。
- 编码:待签名字符串使用 UTF-8。
- 改包重签:修改请求体任意字段后须重新计算
sign。
常见问题
| 现象或问题 | 处理建议 |
|---|---|
| 签名不匹配 | 打印 StrA 与文档示例逐步对比;确认 Key 排序、仅拼接值、无分隔符 |
| 时间戳无效 | 同步 NTP;timestamp 须为 13 位毫秒级 Unix 时间戳 |
| 密钥格式错误 | 私钥为 PKCS#8;粘贴时保留完整 Base64,无多余换行(按示例 PEM 包装) |
| 空值 | null、空字符串、缺省字段均不参与签名;不要对「占位空串」参与拼接 |
| Java 中数字类型 | JsonObject 若用 getAsString(),请保证数字在 JSON 中可按字符串读取,或统一先序列化为字符串再签名 |
如需英文对照,请参阅 英文版签名规则。
