<!-- Source: https://docs.infraio.xyz/zh-TW/webhooks/signature-verification -->
<!-- Last updated: 2026-10-04 -->

# 簽章驗證

每次 Webhook 投遞都會帶上兩個搭配使用的標頭:

```http
X-Signature: sha256=9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b
X-Timestamp: 1729536000
```

`sha256=` 後的十六進位字串是 `HMAC-SHA256(secret, timestamp + "." + raw_body)`。
點號是字面位元組，timestamp 是 ASCII 形式的 Unix 秒。

## 為什麼要驗證

Webhook URL 可能經由代理日誌、截圖、瀏覽器歷史與支援工單外洩。若沒有簽章檢查，任何知道你 URL 的人都能 POST 一個
偽造的 `payment.settled` 事件，讓你為未付款的訂單履約。驗證能證明請求真的來自 InfraIO Pay。

把 timestamp 放進簽章酬載也能提供**防重放**:攻擊者即使捕獲了一次
投遞，過了你的容差視窗後簽章就會被偵測為過期。

## 演算法

```
signed_payload  = timestamp + "." + raw_body
expected_header = "sha256=" + lowercase_hex( HMAC_SHA256(secret, signed_payload) )
constant_time_compare(expected_header, x_signature_header)
```

接著檢查 timestamp 是新的(典型容差:±5 分鐘)。

> **Warning:**
>
> 永遠傳**原始**請求 body 位元組。框架經常在你的處理函式執行前就先
> 解析 JSON;重新序列化後的版本可能與我們發送的不同(鍵序、空白、
> 數字格式),HMAC 就會對不上。Next.js App Router 內，在 `JSON.parse`
> **之前**使用 `await req.text()`。Express 內，僅在 Webhook 路由上
> 掛載 `express.raw({ type: 'application/json' })`。

## 實作範例

**Node / TS**

```ts filename="lib/verify-infraio.ts"
import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 5 * 60;

export function verifyInfraIo({
  body,
  signature,
  timestamp,
  secret,
}: {
  body: string;       // 原始文字 — 非解析過的 JSON
  signature: string;  // X-Signature 標頭的值
  timestamp: string;  // X-Timestamp 標頭的值(Unix 秒)
  secret: string;     // whsec_…
}): boolean {
  const ts = Number.parseInt(timestamp, 10);
  if (!Number.isFinite(ts)) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - ts) > TOLERANCE_SECONDS) {
    return false; // 太舊或太靠未來
  }

  const expected = "sha256=" + createHmac("sha256", secret)
    .update(`${timestamp}.${body}`)
    .digest("hex");

  const a = Buffer.from(signature);
  const b = Buffer.from(expected);
  return a.length === b.length && timingSafeEqual(a, b);
}
```

**Go**

```go
package infraio

import (
    "crypto/hmac"
    "crypto/sha256"
    "crypto/subtle"
    "encoding/hex"
    "strconv"
    "time"
)

const toleranceSeconds = 5 * 60

// VerifyWebhook 當 sig 匹配 HMAC-SHA256(secret, timestamp + "." + body)
// 且 timestamp 落在容差視窗內時回傳 true。
func VerifyWebhook(body []byte, sig, timestamp, secret string) bool {
    ts, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil {
        return false
    }
    skew := time.Now().Unix() - ts
    if skew < 0 {
        skew = -skew
    }
    if skew > toleranceSeconds {
        return false
    }

    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(timestamp))
    mac.Write([]byte("."))
    mac.Write(body)
    expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
    return subtle.ConstantTimeCompare([]byte(sig), []byte(expected)) == 1
}
```

**Python**

```python
import hmac, hashlib, time

TOLERANCE_SECONDS = 5 * 60

def verify_infraio(body: bytes, signature: str, timestamp: str, secret: str) -> bool:
    """body: 原始位元組。signature: 'sha256=<hex>'。timestamp: Unix 秒。"""
    try:
        ts = int(timestamp)
    except ValueError:
        return False
    if abs(int(time.time()) - ts) > TOLERANCE_SECONDS:
        return False

    signed = timestamp.encode() + b"." + body
    expected = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature, expected)
```

**Ruby**

```ruby
require 'openssl'

TOLERANCE_SECONDS = 5 * 60

def verify_infraio(body, signature, timestamp, secret)
  ts = Integer(timestamp) rescue (return false)
  return false if (Time.now.to_i - ts).abs > TOLERANCE_SECONDS

  signed   = "#{timestamp}.#{body}"
  expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", secret, signed)
  Rack::Utils.secure_compare(signature.to_s, expected)
end
```

## 防重放

簽章酬載中的 timestamp 是**第一道**防線 — 捕獲一次投遞的攻擊者在
你的容差視窗過期後就無法再重發。

為了額外的安全(對 `payment.settled` 等高價值事件推薦):

1. **以 `X-Delivery` 去重**,使用帶唯一限制的資料表。容差視窗內的
   重放會變成 no-op — 你的處理函式回 200 而不會做兩次。這正是
   合法重試也需要的冪等性。(`X-Delivery` 在一筆投遞的所有重試間保持穩定;
   酬載中並沒有 `event_id` 欄位。)
2. **使用時鐘漂移所允許的最小容差。** ±5 分鐘是推薦預設，符合
   多數 NTP 同步機群可維持的範圍。更嚴格也可以;但低於 ±30 秒後，
   在上游 NTP 較慢的網路上你會開始拒絕合法的投遞。

## 輪替金鑰

1. **儀表板 → Developers → Webhooks → [endpoint] → ⋮ → Rotate Secret。**
2. 產生新金鑰並**僅顯示一次**。關閉對話框前請複製。
3. 在 24 小時內更新環境變數並重新部署你的驗證器。

### 寬限視窗(雙簽)

輪替後的 **24 小時** 內，每次投遞都會帶兩個簽章:

```http
X-Signature:       sha256=<hmac(new_secret,  ts + "." + body)>
X-Signature-Prev:  sha256=<hmac(prev_secret, ts + "." + body)>
X-Timestamp:       1729536000
```

執行**前一個**金鑰的驗證器會匹配 `X-Signature-Prev`;執行**新**金鑰
的驗證器會匹配 `X-Signature`。任一標頭通過就足夠 — 處理函式可以在
遷移期接受投遞，而不必阻塞部署。

寬限視窗關閉後只發送 `X-Signature`。前一個金鑰不再被接受，任何仍
設定它的驗證器都會開始拒絕投遞 — 所以請在 24 小時預算內完成發佈。

### 建議的接收方模式

```ts
// 在輪替寬限視窗內接受任一簽章。
const sig     = req.headers["x-signature"]      ?? "";
const sigPrev = req.headers["x-signature-prev"] ?? "";
const ok = verify(body, sig, ts, CURRENT_SECRET)
       || (PREV_SECRET && verify(body, sigPrev, ts, PREV_SECRET));
```

一旦你端點的寬限視窗已過期、且 env 中的 `PREV_SECRET` 也已移除，
就可以刪除 `X-Signature-Prev` 的分支。

### 緊急失效

若金鑰公開外洩，你想立刻讓前一個金鑰失效 — 也就是不希望 24 小時
重疊讓已知問題金鑰繼續活著 — 那就輪替兩次。第一次輪替把外洩金鑰
移到 prev 槽;第二次輪替把它從 prev 槽擠出(以仍然新的金鑰取代),
外洩值就再也不被接受。

## 配線測試

在儀表板開啟 **Developers → Webhooks**,於想驗證的端點點 **Send Test**。
我們會同步對 URL 簽章並 POST 一個合成信封，然後顯示 HTTP 狀態、
延遲與你回應的前 512 位元組。酬載形狀:

```json
{
  "event_id":   "<uuid>",
  "event_type": "webhook.test.ping",
  "created_at": "2026-05-17T12:00:00Z",
  "test":       true,
  "data": {
    "merchant_id": "<your-merchant-id>",
    "webhook_id":  "<endpoint-id>",
    "message":     "Test ping from the merchant dashboard..."
  }
}
```

測試 ping 與正式投遞使用相同的簽章方案，所以此按鈕出現綠勾即可
確認你的驗證器也能接受真實事件。測試 ping 不會重試。
若想演練重試，請透過對應的 API 流程觸發真實事件。

## 常見失敗

| 症狀 | 可能原因 |
| --- | --- |
| 開發環境永遠回 false | HMAC 計算前 body 已被 JSON 解析。請先讀取原始位元組。 |
| 昨天還能用，今天失敗 | 你輪替了金鑰但這台伺服器的 env var 還是舊的。請用新金鑰重新部署。 |
| 舊事件失敗，新事件成功 | 一次投遞在輪替前已入佇列;簽章用舊金鑰而你的驗證器不再接受。請等重試自然丟棄，或透過儀表板重放。 |
| 時間戳比較差一 | 確認以 Unix 秒比較 Unix 秒。JS 的 `Date.now()` 是**毫秒** — 需除以 1000。 |
| 本機正常，正式失敗 | 代理(Cloudflare、nginx)在解壓、重新編碼或剝掉了尾端換行。請檢查處理函式實際看到的位元組。 |
| 測試 ping 回 401 / 簽章不符 | 你的驗證器只簽了 `body`。請改成簽 `timestamp + "." + body`。 |
| 整個標頭遺失 | 端點註冊在另一個環境下。Test 模式端點只接收 `environment=test` 事件。 |
