Skip to content

接口鉴权与签名机制 ​

SureLink 网关开放接口采用行业最高安全标准的 HMAC-SHA256 对称签名鉴权 体系,防范报文篡改、重放攻击与未授权调用。所有访问 /api/v1/* 的商户接口请求都必须携带规范的鉴权请求头。


1. 鉴权请求头规范 (Headers) ​

商户在调用网关 API 时,必须在 HTTP Request Header 中提供以下字段:

请求头字段类型是否必填说明与约束
X-Api-Keystring必填商户的 API Key 公钥标识。
X-Timestampstring必填请求发起时的 毫秒级 Unix 时间戳(如 1715000000000)。网关要求客户端时钟与服务器时钟偏差必须在 $\pm 5$ 分钟 (300,000 ms) 以内。
X-Noncestring必填客户端生成的随机字符串,长度必须在 16 ~ 64 位字符 之间。每个 API Key 的 Nonce 在 600 秒内不可重复。
X-Signaturestring必填依据规范计算得出的 HMAC-SHA256 签名,固定为 64 位小写十六进制字符串 (Hex)。
Idempotency-Keystring推荐(POST必填)客户端生成的唯一业务幂等键(如 UUID),防网络抖动重复扣款/下单。如未提供,订单接口将降级取 merchantOrderNo。

安全提示

网关在校验失败时统一返回 401 Unauthorized (errorCode: "MERCHANT_API_UNAUTHORIZED"),绝不在错误报文中透露具体是密钥错误、时间戳越界还是签名不匹配,以防止恶意攻击者试探。


2. 签名算法详解 ​

2.1 待签名源串 (Signing Material) ​

签名源串由 时间戳、随机数 与 原始请求体 (Raw Body) 以英文句点 . 拼接而成:

$$\text{Signing String} = \text{X-Timestamp} + \text{"."} + \text{X-Nonce} + \text{"."} + \text{RawRequestBody}$$

关键注意事项: ​

  1. Raw Body 字节一致性:必须使用 HTTP 请求体原始未加工的 JSON 字符串(即网络传输的原始字节流),严禁在解析或反序列化后重新序列化为 JSON(否则键排序或空格差异会导致签名不一致)。
  2. GET 请求或空 Body:对于 GET、DELETE 或无请求体的请求,RawRequestBody 视为空字符串 ""。此时待签名串为: $$\text{Signing String} = \text{X-Timestamp} + \text{"."} + \text{X-Nonce} + \text{"."}$$ (注意末尾保留英文句点 .)

2.2 计算 HMAC-SHA256 摘要 ​

使用商户分配到的 API Secret 作为密钥,采用 SHA-256 哈希算法对待签名源串计算 HMAC,并将二进制结果格式化为 全小写的十六进制字符串 (Hex Digest)。

$$\text{X-Signature} = \operatorname{HMAC-SHA256}_{\text{API Secret}}(\text{Signing String}).\text{toHex()}$$


3. 防重放与防刷机制 ​

网关内部实施多层安全防护:

  1. 时间戳窗口校验:服务器对比当前时间与 X-Timestamp,绝对差值超过 300 秒直接拒绝。
  2. Redis NX 随机数排重:网关以 hmac_nonce:${apiKey}:${nonce} 为键执行原子写入,并设置 10 分钟 (600s) TTL。由于 Nonce 存活时间(10 分钟)大于时间戳允许的最大漂移窗口(5 分钟),即使攻击者在边界时间截获报文,也绝不可能实施重放攻击。
  3. 商户 IP 白名单:若商户在后台配置了 IP 白名单,非白名单客户端 IP 将被拦截并返回 403 Forbidden (IP_NOT_WHITELISTED)。

4. 多语言签名实现代码示例 ​

typescript
import { createHmac, randomBytes } from 'node:crypto';

interface RequestOptions {
  apiKey: string;
  apiSecret: string;
  method: string;
  url: string;
  body?: Record<string, any>;
}

export async function sendSignedRequest({ apiKey, apiSecret, method, url, body }: RequestOptions) {
  const timestamp = Date.now().toString();
  // 生成 32 字符的 16 进制随机字符串 (16 bytes = 32 hex chars)
  const nonce = randomBytes(16).toString('hex');
  const rawBody = body ? JSON.stringify(body) : '';

  // 拼接签名源串: ${timestamp}.${nonce}.${rawBody}
  const source = `${timestamp}.${nonce}.${rawBody}`;
  const signature = createHmac('sha256', apiSecret).update(source, 'utf8').digest('hex');

  const headers: Record<string, string> = {
    'Content-Type': 'application/json',
    'X-Api-Key': apiKey,
    'X-Timestamp': timestamp,
    'X-Nonce': nonce,
    'X-Signature': signature,
  };

  if (method.toUpperCase() === 'POST' && body?.merchantOrderNo) {
    headers['Idempotency-Key'] = body.merchantOrderNo;
  }

  const response = await fetch(url, {
    method,
    headers,
    body: rawBody || undefined,
  });

  return response.json();
}
python
import time
import secrets
import hmac
import hashlib
import json
import requests

def make_signed_request(api_key: str, api_secret: str, method: str, url: str, body: dict = None):
    timestamp = str(int(time.time() * 1000))
    # 随机生成 32 位 hex 字符串
    nonce = secrets.token_hex(16)
    raw_body = json.dumps(body, separators=(',', ':')) if body else ""

    # 构造待签名串: ${timestamp}.${nonce}.${raw_body}
    source = f"{timestamp}.{nonce}.{raw_body}"
    signature = hmac.new(
        api_secret.encode('utf-8'),
        source.encode('utf-8'),
        hashlib.sha256
    ).hexdigest()

    headers = {
        "Content-Type": "application/json",
        "X-Api-Key": api_key,
        "X-Timestamp": timestamp,
        "X-Nonce": nonce,
        "X-Signature": signature
    }

    if method.upper() == "POST" and body and "merchantOrderNo" in body:
        headers["Idempotency-Key"] = body["merchantOrderNo"]

    resp = requests.request(method, url, headers=headers, data=raw_body.encode('utf-8') if raw_body else None)
    return resp.json()
java
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;

public class SureLinkSigner {
    private static final String HMAC_SHA256 = "HmacSHA256";

    public static String computeHmacHex(String secret, String message) throws Exception {
        Mac mac = Mac.getInstance(HMAC_SHA256);
        SecretKeySpec keySpec = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_SHA256);
        mac.init(keySpec);
        byte[] rawHmac = mac.doFinal(message.getBytes(StandardCharsets.UTF_8));
        
        StringBuilder hexString = new StringBuilder();
        for (byte b : rawHmac) {
            String hex = Integer.toHexString(0xff & b);
            if (hex.length() == 1) hexString.append('0');
            hexString.append(hex);
        }
        return hexString.toString();
    }

    public static String generateNonce() {
        SecureRandom random = new SecureRandom();
        byte[] bytes = new byte[16];
        random.nextBytes(bytes);
        StringBuilder sb = new StringBuilder();
        for (byte b : bytes) {
            sb.append(String.format("%02x", b));
        }
        return sb.toString();
    }
}
go
package main

import (
	"crypto/hmac"
	"crypto/rand"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"time"
)

func generateNonce() (string, error) {
	bytes := make([]byte, 16)
	if _, err := rand.Read(bytes); err != nil {
		return "", err
	}
	return hex.EncodeToString(bytes), nil
}

func computeSignature(secret, timestamp, nonce, rawBody string) string {
	source := fmt.Sprintf("%s.%s.%s", timestamp, nonce, rawBody)
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(source))
	return hex.EncodeToString(mac.Sum(nil))
}

func main() {
	apiKey := "mch_key_demo123"
	apiSecret := "sec_live_9876543210fedcba"
	timestamp := fmt.Sprintf("%d", time.Now().UnixMilli())
	nonce, _ := generateNonce()
	rawBody := `{"merchantOrderNo":"MCH20261011001","assetAmount":"100.000000"}`

	signature := computeSignature(apiSecret, timestamp, nonce, rawBody)
	fmt.Printf("X-Signature: %s\n", signature)
}
php
<?php

function buildSignedHeaders(string $apiKey, string $apiSecret, string $rawBody = ''): array {
    $timestamp = (string) round(microtime(true) * 1000);
    $nonce = bin2hex(random_bytes(16)); // 32 hex chars

    $source = "{$timestamp}.{$nonce}.{$rawBody}";
    $signature = hash_hmac('sha256', $source, $apiSecret);

    return [
        'Content-Type: application/json',
        "X-Api-Key: {$apiKey}",
        "X-Timestamp: {$timestamp}",
        "X-Nonce: {$nonce}",
        "X-Signature: {$signature}"
    ];
}
bash
# 假设施行前已计算出变量
TIMESTAMP="1715000000000"
NONCE="9f8e7d6c5b4a392817263544a1b2c3d4"
SIGNATURE="5a228f237ef81d6fbb5d0e7403aefbb94adbb4a6435c44f77c3e1e24748c0827"

curl -X POST "https://api-sandbox.surelink.io/api/v1/orders" \
  -H "Content-Type: application/json" \
  -H "X-Api-Key: your_api_key_here" \
  -H "X-Timestamp: $TIMESTAMP" \
  -H "X-Nonce: $NONCE" \
  -H "X-Signature: $SIGNATURE" \
  -H "Idempotency-Key: MCH_ORDER_20261011_001" \
  -d '{"merchantOrderNo":"MCH_ORDER_20261011_001","assetAmount":"100.000000"}'