Skip to content

🔐 签名算法(Signature)

IntPay 为所有接口通信提供 双向签名机制(Bidirectional Signature),用于确保:

  • 请求来源可信(Authentication)
  • 数据未被篡改(Integrity)
  • 通信过程安全(Security)

一、签名机制说明

在以下场景中,必须进行签名或验签

  • 请求 API 时:必须签名
  • 接收 API 响应时:必须验签
  • 接收异步通知(Webhook)时:必须验签
  • 返回通知响应:无需签名

二、签名类型

类型适用场景特点
POST 签名API 请求 / API 响应 / 异步通知Header 传递签名,Body 参与签名
GET 签名页面跳转(ReturnUrl)签名附加在 URL Query 中

三、POST 签名规则

3.1 签名流程

  1. 生成请求时间戳(毫秒)
  2. 构造规范化 JSON(Canonical JSON)
  3. 构造签名基串(Sign Base)
  4. 拼接密钥并计算 MD5
  5. 将签名写入 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=54f99a2491f7008833eb3e72b5d141fd

3.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=009287166333c45b7f0fd127b2c09fc2

4.2 验签流程

  1. 使用响应体构造 canonical JSON
  2. 读取响应头中的 Tenant-IdResponse-TimeSignature
  3. 按 POST 规则重新拼接签名基串
  4. 使用本地密钥计算 MD5
  5. 与响应头中的 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 签名流程

商户收到同步跳转请求后,应按以下步骤重新计算签名:

  1. 提取所有 URL Query 参数
  2. 排除 signature 字段
  3. 按 key 的 ASCII 字典序升序排序
  4. key=value 形式使用 & 拼接
  5. 在末尾追加 &key=商户密钥
  6. 计算 MD5(UTF-8,小写)
  7. 与 URL 中的 signature 进行比较

6.3 示例 URL

text
https://api.link-pays.com/return?amount=10000&payId=P251012110501493641651&reference=R202512040&status=SUCCEEDED&signature=f7c9428b5eab2056ff2efe376e75d904

6.4 解析后的参数

  • amount = 10000
  • payId = P251012110501493641651
  • reference = R202512040
  • status = SUCCEEDED
  • signature = f7c9428b5eab2056ff2efe376e75d904

signature 仅用于最终比对,不参与签名串拼接。
所有参数应以 URL 解码后的原始值 参与签名。

6.5 参数排序

text
amount, payId, reference, status

6.6 待签名字符串

text
amount=10000&payId=P251012110501493641651&reference=R202512040&status=SUCCEEDED

6.7 拼接密钥

text
amount=10000&payId=P251012110501493641651&reference=R202512040&status=SUCCEEDED&key=lfzqfim2sa9hbj3l57xupzp9pzrjojni

6.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 摘要计算,
以校验数据完整性与来源可信性。