🔐 签名算法(Signature)
IntPay 为所有接口通信提供 双向签名机制(Bidirectional Signature),用于确保:
- 请求来源可信(Authentication)
- 数据未被篡改(Integrity)
- 通信过程安全(Security)
一、签名机制说明
在以下场景中,必须进行签名或验签:
- 请求 API 时:必须签名
- 接收 API 响应时:必须验签
- 接收异步通知(Webhook)时:必须验签
- 返回通知响应:无需签名
二、签名类型
| 类型 | 适用场景 | 特点 |
|---|---|---|
| POST 签名 | API 请求 / API 响应 / 异步通知 | Header 传递签名,Body 参与签名 |
| GET 签名 | 页面跳转(ReturnUrl) | 签名附加在 URL Query 中 |
三、POST 签名规则
3.1 签名流程
- 生成请求时间戳(毫秒)
- 构造规范化 JSON(Canonical JSON)
- 构造签名基串(Sign Base)
- 拼接密钥并计算 MD5
- 将签名写入 Header
3.2 Canonical JSON 规则
请求体需进行标准化处理:
- 按字段 字典序升序排序
- 移除所有
null值 - 保留空字符串
"" - 数组顺序保持不变
3.3 签名基串格式
text
<http-method> <http-uri>
<tenant-id>.<request-time>.<canonical-json>字段说明
| 字段 | 说明 |
|---|---|
| http-method | 请求方法,如 POST |
| http-uri | 请求路径(不包含域名) |
| tenant-id | 商户号 |
| request-time | 毫秒时间戳 |
| canonical-json | 规范化 JSON 字符串 |
示例
text
POST /payment/payments
220818025875.1760238053626.{"amount":10000,"website":"www.links-pay.cc"...}3.4 签名生成
使用 MD5 计算签名:
java
public static String calculateSignature(
String method,
String path,
String tenantId,
String timestamp,
String signKey,
String canonicalJson
) {
String signBase = String.format(
"%s %s\n%s.%s.%s",
method.toUpperCase(),
path,
tenantId,
timestamp,
canonicalJson
);
return SecureUtil.md5(signBase + signKey);
}3.5 请求头示例
http
Content-Type: application/json
Tenant-Id: 220818025875
Request-Time: 1760238053626
Signature: algorithm=MD5,keyVersion=1,signature=54f99a2491f7008833eb3e72b5d141fd3.6 完整请求示例
http
POST /payment/payments
Host: api.links-pay.com
Content-Type: application/json
Tenant-Id: 220818025875
Request-Time: 1760238053626
Signature: algorithm=MD5,keyVersion=1,signature=54f99a2491f7008833eb3e72b5d141fd
{
"amount": 10000,
"website": "www.links-pay.cc",
"subject": "Women’s 3D Embroidered Bandeau Elegant Mini Dress",
"channel": "Checkout",
"type": "hpp",
"billing": {
"country": "US",
"firstName": "Melissa",
"lastName": "Hughes",
"address": "1225 Wilshire Blvd Apt 804",
"city": "Los Angeles",
"phone": "+14155527689",
"postalCode": "90017",
"state": "CA",
"email": "melissa.hughes@example.com"
},
"reference": "R202512040",
"shipping": {
"country": "US",
"firstName": "Melissa",
"lastName": "Hughes",
"address": "1225 Wilshire Blvd Apt 804",
"city": "Los Angeles",
"phone": "+14155527689",
"postalCode": "90017",
"state": "CA",
"email": "melissa.hughes@example.com"
},
"notifyUrl": "https://www.links-pay.cc/notify",
"currency": "USD",
"returnUrl": "https://www.links-pay.cc/checkout.html",
"items": [
{
"unitPrice": 10000,
"name": "Women’s 3D Embroidered Bandeau Elegant Mini Dress",
"currency": "USD",
"quantity": "1",
"category": "Women Clothing",
"totalPrice": 10000
}
],
"browserInfo": {
"screenWidth": 1920,
"javaEnabled": false,
"os": "Windows 10",
"screenHeight": 1080,
"ipAddress": "168.168.168.168",
"browserName": "Chrome",
"timeZone": -480,
"javascriptEnabled": true,
"language": "en-US",
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
"colorDepth": 24
}
}四、响应验签(Response Verification)
平台返回数据时,会在 Header 中附带签名。商户侧必须按相同规则进行验签,以验证响应内容是否完整且可信。
4.1 响应头示例
http
Content-Type: application/json
Tenant-Id: 220818025875
Response-Time: 1760238317933
Signature: algorithm=MD5,keyVersion=1,signature=009287166333c45b7f0fd127b2c09fc24.2 验签流程
- 使用响应体构造 canonical JSON
- 读取响应头中的
Tenant-Id、Response-Time、Signature - 按 POST 规则重新拼接签名基串
- 使用本地密钥计算 MD5
- 与响应头中的
signature比较
4.3 验签代码示例
java
/**
* 客户端:验证服务端响应签名(只支持 POST 场景)
*
* @param requestUrl 你当时请求用的完整 URL(用于提取 PATH)
* @param tenantId 商户号
* @param signKey MD5 密钥
* @param httpResp 服务端返回对象
* @return true = 验签通过
*/
public static boolean verifyResponseSignature(
String requestUrl,
String tenantId,
String signKey,
cn.hutool.http.HttpResponse httpResp
) {
try {
URI parsed = URI.create(requestUrl);
String path = parsed.getPath();
String respTenantId = httpResp.header(HDR_TENANT_ID);
String responseTime = httpResp.header("Response-Time");
String signatureHead = httpResp.header(HDR_SIGNATURE);
if (cn.hutool.core.util.StrUtil.hasBlank(respTenantId, responseTime, signatureHead)) {
return false;
}
if (!tenantId.equals(respTenantId)) {
return false;
}
Map<String, String> sig = parseSignatureHeader(signatureHead);
String algorithm = sig.getOrDefault("algorithm", "");
String provided = sig.getOrDefault("signature", "");
if (!"MD5".equalsIgnoreCase(algorithm) || cn.hutool.core.util.StrUtil.isBlank(provided)) {
return false;
}
String bodyRaw = httpResp.body();
String canonicalJson = buildCanonicalJsonFromRaw(bodyRaw);
String signBase = String.format(
"POST %s\n%s.%s.%s",
path,
tenantId,
responseTime,
canonicalJson
);
String localSig = cn.hutool.crypto.SecureUtil.md5(signBase + signKey);
return constantTimeEquals(localSig, provided);
} catch (Exception e) {
e.printStackTrace();
return false;
}
}五、异步通知验签(Webhook Verification)
异步通知的验签规则与 POST 请求保持一致。收到通知后,应立即完成签名校验,再进行业务处理。
5.1 实现建议
- 仅接受
POST请求 - 必须使用原始请求体进行验签
- 建议增加时间窗口校验,防止重放攻击
- 验签失败时不要更新订单状态
5.2 通知处理示例
java
@PostMapping("/notify")
public R backUrl(HttpServletRequest req, @RequestBody Map<String, Object> map) throws Exception {
System.out.println("收到回调:" + JSON.toJSONString(map));
long allowedSkewMs = 5 * 60 * 1000;
String canonicalJson = PaySignedClient.buildCanonicalJson(map);
boolean verified = PaySignedClient.verifySignature(
req,
canonicalJson,
"0f3ywtwkase1yx7a5rbkeffealzrvuox",
allowedSkewMs
);
System.out.println("验签结果:" + verified);
return R.ok();
}5.3 服务端验签代码示例
java
/**
* 服务端:验签(只允许 POST)
*
* @param request HttpServletRequest(必须为 POST)
* @param rawBody 原始请求体(未经修改的 JSON 字符串)
* @param signKey 商户密钥
* @param allowedSkewMs 允许的时间偏移(毫秒)
*/
public static boolean verifySignature(
HttpServletRequest request,
String rawBody,
String signKey,
long allowedSkewMs
) {
if (!"POST".equalsIgnoreCase(request.getMethod())) return false;
String path = request.getRequestURI();
String tenantId = request.getHeader(HDR_TENANT_ID);
String requestTime = request.getHeader(HDR_REQUEST_TIME);
String signatureHead = request.getHeader(HDR_SIGNATURE);
if (StrUtil.hasBlank(tenantId, requestTime, signatureHead)) return false;
long now;
long ts;
try {
now = System.currentTimeMillis();
ts = Long.parseLong(requestTime);
} catch (NumberFormatException e) {
return false;
}
if (Math.abs(now - ts) > allowedSkewMs) return false;
Map<String, String> sig = parseSignatureHeader(signatureHead);
String algorithm = sig.getOrDefault("algorithm", "");
String provided = sig.getOrDefault("signature", "");
if (!"MD5".equalsIgnoreCase(algorithm) || StrUtil.isBlank(provided)) return false;
String canonicalJson = buildCanonicalJsonFromRaw(rawBody);
String signBase = String.format(
"POST %s\n%s.%s.%s",
path,
tenantId,
requestTime,
canonicalJson
);
String localSig = SecureUtil.md5(signBase + signKey);
return constantTimeEquals(localSig, provided);
}5.4 Canonical JSON 构造示例
java
public static String buildCanonicalJson(Object obj) {
if (obj == null) return "{}";
Object tree = JSON.toJSON(obj);
Object cleaned = removeNullRecursively(tree);
return JSON.toJSONString(
cleaned,
SerializerFeature.MapSortField,
SerializerFeature.SortField,
SerializerFeature.DisableCircularReferenceDetect,
SerializerFeature.WriteBigDecimalAsPlain
);
}六、GET 签名规则(ReturnUrl)
当支付完成后,系统会同步跳转至商户指定的 returnUrl。此时使用 GET 签名模式,将签名参数直接附加在 URL 上。
6.1 返回参数说明
| 参数 | 说明 |
|---|---|
| amount | 交易金额,单位:分 |
| status | 支付状态 |
| payId | 平台交易号 |
| reference | 商户订单号 |
| failureMessage | 失败原因(仅失败时返回) |
| signature | 签名值 |
6.2 签名流程
商户收到同步跳转请求后,应按以下步骤重新计算签名:
- 提取所有 URL Query 参数
- 排除
signature字段 - 按 key 的 ASCII 字典序升序排序
- 按
key=value形式使用&拼接 - 在末尾追加
&key=商户密钥 - 计算 MD5(UTF-8,小写)
- 与 URL 中的
signature进行比较
6.3 示例 URL
text
https://api.link-pays.com/return?amount=10000&payId=P251012110501493641651&reference=R202512040&status=SUCCEEDED&signature=f7c9428b5eab2056ff2efe376e75d9046.4 解析后的参数
amount = 10000payId = P251012110501493641651reference = R202512040status = SUCCEEDEDsignature = f7c9428b5eab2056ff2efe376e75d904
signature仅用于最终比对,不参与签名串拼接。
所有参数应以 URL 解码后的原始值 参与签名。
6.5 参数排序
text
amount, payId, reference, status6.6 待签名字符串
text
amount=10000&payId=P251012110501493641651&reference=R202512040&status=SUCCEEDED6.7 拼接密钥
text
amount=10000&payId=P251012110501493641651&reference=R202512040&status=SUCCEEDED&key=lfzqfim2sa9hbj3l57xupzp9pzrjojni6.8 计算签名
text
signature = MD5("amount=10000&payId=P251012110501493641651&reference=R202512040&status=SUCCEEDED&key=lfzqfim2sa9hbj3l57xupzp9pzrjojni")七、常见错误
| 错误类型 | 示例 | 原因 |
|---|---|---|
| 未排序 | payId=P123456&amount=1000... | 参数顺序错误导致签名不一致 |
| 跳过空字符串 | remark="" | 空字符串必须参与签名,null 不参与 |
| 未拼接 key | 缺少 &key=xxxx | 签名基础串不完整 |
| 字符集不一致 | 非 UTF-8 编码 | MD5 结果不同 |
| 大小写不一致 | 本地 MD5 大写,服务端小写 | 建议统一使用小写比较 |
八、最佳实践
- 所有签名和验签逻辑建议在服务端完成
- 不要在前端暴露商户密钥
- 使用统一的 Canonical JSON 工具方法,避免不同模块行为不一致
- 建议记录签名基串与验签结果,便于排查问题
- 异步通知应作为最终支付结果依据
九、核心总结
IntPay 的签名本质上是:
对 规范化数据(Canonical Data)+ 商户密钥 进行 MD5 摘要计算,
以校验数据完整性与来源可信性。