接口鉴权与签名机制
SureLink 网关开放接口采用行业最高安全标准的 HMAC-SHA256 对称签名鉴权 体系,防范报文篡改、重放攻击与未授权调用。所有访问 /api/v1/* 的商户接口请求都必须携带规范的鉴权请求头。
1. 鉴权请求头规范 (Headers)
商户在调用网关 API 时,必须在 HTTP Request Header 中提供以下字段:
| 请求头字段 | 类型 | 是否必填 | 说明与约束 |
|---|---|---|---|
X-Api-Key | string | 必填 | 商户的 API Key 公钥标识。 |
X-Timestamp | string | 必填 | 请求发起时的 毫秒级 Unix 时间戳(如 1715000000000)。网关要求客户端时钟与服务器时钟偏差必须在 $\pm 5$ 分钟 (300,000 ms) 以内。 |
X-Nonce | string | 必填 | 客户端生成的随机字符串,长度必须在 16 ~ 64 位字符 之间。每个 API Key 的 Nonce 在 600 秒内不可重复。 |
X-Signature | string | 必填 | 依据规范计算得出的 HMAC-SHA256 签名,固定为 64 位小写十六进制字符串 (Hex)。 |
Idempotency-Key | string | 推荐(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}$$
关键注意事项:
- Raw Body 字节一致性:必须使用 HTTP 请求体原始未加工的 JSON 字符串(即网络传输的原始字节流),严禁在解析或反序列化后重新序列化为 JSON(否则键排序或空格差异会导致签名不一致)。
- 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. 防重放与防刷机制
网关内部实施多层安全防护:
- 时间戳窗口校验:服务器对比当前时间与
X-Timestamp,绝对差值超过 300 秒直接拒绝。 - Redis NX 随机数排重:网关以
hmac_nonce:${apiKey}:${nonce}为键执行原子写入,并设置 10 分钟 (600s) TTL。由于 Nonce 存活时间(10 分钟)大于时间戳允许的最大漂移窗口(5 分钟),即使攻击者在边界时间截获报文,也绝不可能实施重放攻击。 - 商户 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"}'