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"}'