接口鑑權與簽名機制
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"}'