<!-- Source: https://docs.infraio.xyz/zh-CN/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` 事件。 |
