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

# Xác thực chữ ký

Mọi delivery webhook bao gồm hai header dùng cùng nhau:

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

Chuỗi hex sau `sha256=` là `HMAC-SHA256(secret, timestamp + "." + raw_body)`.
Dấu chấm là một byte literal; timestamp là unix-seconds dưới dạng ASCII.

## Vì sao phải xác thực

URL webhook có thể bị lộ qua log proxy, screenshot, lịch sử trình
duyệt, và ticket support. Không có kiểm tra chữ ký,
bất kỳ ai biết URL của bạn đều có thể POST một event `payment.settled`
giả và lừa bạn fulfillment các order chưa thanh toán. Xác thực chứng
minh rằng request đến từ InfraIO Pay.

Bao gồm cả timestamp bên trong payload đã ký cũng cho bạn **chống
replay**: một attacker capture được một delivery không thể gửi lại
sau này mà không khiến chữ ký trở nên detectably stale.

## Thuật toán

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

Sau đó kiểm tra timestamp gần đây (tolerance điển hình: ±5 phút).

> **Warning:**
>
> Luôn truyền **raw** bytes của request body. Framework thường parse
> JSON trước khi handler của bạn chạy; phiên bản re-stringify có thể
> khác với cái chúng tôi gửi (thứ tự key, whitespace, định dạng số),
> và HMAC sẽ không khớp. Trong Next.js App Router dùng
> `await req.text()` *trước* `JSON.parse`. Trong Express, mount
> `express.raw({ type: 'application/json' })` chỉ trên route webhook.

## Cài đặt

**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;       // text raw — KHÔNG phải JSON đã parse
  signature: string;  // giá trị của header X-Signature
  timestamp: string;  // giá trị của header X-Timestamp (unix seconds)
  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; // quá cũ hoặc quá xa trong tương lai
  }

  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 returns true iff sig matches
// HMAC-SHA256(secret, timestamp + "." + body) AND the timestamp
// is within the tolerance window.
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: raw bytes. signature: 'sha256=<hex>'. timestamp: unix seconds."""
    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
```

## Chống replay

Timestamp bên trong payload đã ký là **lớp** phòng vệ đầu tiên — một
attacker capture được một delivery không thể gửi lại sau khi cửa sổ
tolerance của bạn hết hạn.

Để an toàn hơn (khuyến nghị cho event giá trị cao như
`payment.settled`):

1. **Dedup trên `X-Delivery`** trong một bảng với unique constraint.
   Replay trong cửa sổ tolerance trở thành no-op — handler của bạn
   trả 200 mà không làm việc gấp đôi. Đây là cùng idempotency bạn
   muốn cho retry hợp pháp. (`X-Delivery` ổn định qua mọi retry của
   một delivery; payload không có trường `event_id`.)
2. **Dùng tolerance nhỏ nhất mà drift đồng hồ của bạn cho phép.** ±5
   phút là mặc định khuyến nghị và khớp với những gì hầu hết fleet
   đã đồng bộ NTP có thể sustain. Chặt hơn vẫn ổn; dưới ±30 giây bạn
   sẽ bắt đầu reject delivery hợp pháp trên network có NTP upstream
   chậm.

## Rotate một secret

1. **Dashboard → Developers → Webhooks → [endpoint] → ⋮ → Rotate Secret.**
2. Một secret mới được sinh và hiển thị **đúng một lần**. Copy nó
   trước khi đóng dialog.
3. Cập nhật env var và redeploy verifier của bạn trong vòng 24 giờ.

### Cửa sổ grace (dual-sign)

Trong **24 giờ** sau khi rotate, mọi delivery mang hai chữ ký:

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

Một verifier chạy secret **trước đó** match `X-Signature-Prev`; một
verifier chạy secret **mới** match `X-Signature`. Một trong hai header
pass là đủ — handler của bạn có thể chấp nhận delivery trong quá trình
migration mà không cần giữ deploy.

Sau khi cửa sổ grace đóng, chỉ `X-Signature` được gửi. Secret trước
đó ngừng được chấp nhận và bất kỳ verifier nào vẫn cấu hình với nó
sẽ bắt đầu reject delivery — nên hãy kết thúc rollout trong budget
24 giờ.

### Pattern receiver gợi ý

```ts
// Chấp nhận một trong hai chữ ký trong cửa sổ grace rotate.
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));
```

Bạn có thể bỏ nhánh `X-Signature-Prev` ngay khi cửa sổ grace trên
endpoint của bạn đã hết và bạn đã loại bỏ `PREV_SECRET` khỏi env.

### Revoke khẩn cấp

Nếu một secret bị lộ công khai và bạn cần vô hiệu hóa secret trước
đó ngay lập tức — tức là bạn không muốn overlap 24 giờ giữ một key
đã biết là bad sống — rotate hai lần. Rotate đầu tiên chuyển secret
bị lộ vào slot prev; rotate thứ hai đẩy nó ra khỏi slot prev (thay
thế bằng key vẫn mới) để giá trị bị lộ không còn được chấp nhận nữa.

## Test wiring của bạn

Trong dashboard, mở **Developers → Webhooks** và bấm **Send Test**
trên endpoint bạn muốn xác minh. Chúng tôi ký và POST một envelope
synthetic đến URL đồng bộ, rồi hiển thị HTTP status, latency, và
snippet 512 byte của response. Hình dáng payload:

```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..."
  }
}
```

Test ping dùng cùng scheme ký như delivery production, nên một check
xanh từ nút này xác nhận verifier của bạn chấp nhận cả event thật.
Test ping không được retry. Để exercise retry, kích hoạt một event thật
qua luồng API liên quan.

## Lỗi thường gặp

| Triệu chứng | Nguyên nhân khả dĩ |
| --- | --- |
| Luôn trả false trong dev | Body đã được JSON-parse trước HMAC. Đọc raw bytes trước. |
| Hôm qua chạy, hôm nay fail | Bạn đã rotate secret nhưng env var trên server này vẫn còn cái cũ. Redeploy với secret mới. |
| Fail với event cũ, hoạt động với event mới | Một delivery được xếp hàng đợi trước khi rotate; chữ ký dùng secret cũ và verifier của bạn không còn chấp nhận. Chờ retry drop nó hoặc replay qua dashboard. |
| Off-by-one trong so sánh timestamp | Đảm bảo bạn so sánh unix-seconds với unix-seconds. `Date.now()` trong JS là **milliseconds** — chia cho 1000. |
| Chạy local OK, fail trên prod | Một proxy (Cloudflare, nginx) đang decompress, re-encode, hoặc strip một trailing newline. Inspect bytes mà handler của bạn thấy. |
| Test ping báo 401 / signature mismatch | Verifier của bạn đang ký chỉ `body`. Hãy ký `timestamp + "." + body`. |
| Header thiếu hoàn toàn | Endpoint được đăng ký cho môi trường khác. Endpoint test-mode chỉ nhận event `environment=test`. |
