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

# 署名検証

すべての Webhook 配信は、2 つのヘッダーを組み合わせて使います:

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

`sha256=` の後の 16 進文字列は `HMAC-SHA256(secret, timestamp + "." + raw_body)` です。
ドットはリテラルなバイト、タイムスタンプは ASCII の Unix 秒です。

## なぜ検証するのか

Webhook URL は、プロキシのログ、スクリーンショット、ブラウザ履歴、
サポートチケットなどから漏れることがあります。署名チェックがなければ、
URL を知った誰でも偽の `payment.settled` イベントを POST して、
未払いの注文をフルフィルさせることができてしまいます。検証は、
リクエストが InfraIO Pay から来たことを証明します。

タイムスタンプを署名対象に含めることで、**リプレイ対策** も提供されます:
配信を捕捉した攻撃者でも、許容ウィンドウを過ぎると署名が古くなり
検出できるようになります。

## アルゴリズム

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

その後、タイムスタンプが新しいことを確認します (一般的な許容範囲: ±5 分)。

> **Warning:**
>
> 常に **生** のリクエスト本文バイトを渡してください。フレームワークは
> ハンドラが動く前に 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
```

## リプレイ対策

署名ペイロードに含まれるタイムスタンプが **最前線** の防御です — 配信を
捕捉した攻撃者でも、許容ウィンドウを過ぎた後はリプレイできません。

念のための追加対策 (`payment.settled` のような重要イベントには推奨):

1. **`X-Delivery` での重複排除。** 一意制約付きのテーブルを使います。
   許容ウィンドウ内のリプレイは no-op となり、ハンドラは作業を二度行わずに
   200 を返します。これは正規のリトライにも欲しい冪等性そのものです。
   (`X-Delivery` は配信のすべてのリトライをまたいで安定しており、ペイロード
   に `event_id` フィールドは含まれません。)
2. **時計のドリフトが許す最小の許容範囲を使う。** ±5 分が推奨デフォルトで、
   NTP 同期されたほとんどのフリートで維持できる範囲です。それより厳しくしても
   構いません; ±30 秒を下回ると、上流の NTP が遅いネットワークでは正規の
   配信を拒否し始めます。

## シークレットのローテーション

1. **ダッシュボード → Developers → Webhooks → [endpoint] → ⋮ → Rotate Secret。**
2. 新しいシークレットが生成され、**1 度だけ** 表示されます。ダイアログを
   閉じる前にコピーしてください。
3. 環境変数を更新し、24 時間以内に検証側を再デプロイしてください。

### 猶予ウィンドウ (デュアル署名)

ローテーション後の **24 時間** は、すべての配信に 2 つの署名が付きます:

```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));
```

エンドポイントの猶予ウィンドウが切れて、`PREV_SECRET` を env から
削除し終えたら、`X-Signature-Prev` のブランチも削除して構いません。

### 緊急失効

シークレットが公に漏れて前のシークレットを即座に無効化したい場合 —
つまり 24 時間オーバーラップで既知の不正鍵が生き続けるのを避けたい場合 —
2 回ローテーションしてください。1 回目のローテーションで漏れたシークレットが
prev スロットに移り、2 回目のローテーションで 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 計算前に本文が JSON パースされている。先に生バイトを読んでください。 |
| 昨日まで動いていたが今日失敗する | シークレットをローテーションしたがこのサーバーの env var がまだ旧値。新しいシークレットで再デプロイしてください。 |
| 古いイベントで失敗、新しいイベントは成功 | ローテーション前にキューに入った配信です; 署名は旧シークレットを使い、検証側はもう受け入れません。リトライがドロップするのを待つか、ダッシュボードからリプレイしてください。 |
| タイムスタンプ比較のオフバイワン | Unix 秒同士で比較していることを確認してください。JS の `Date.now()` は **ミリ秒** — 1000 で割ってください。 |
| ローカルでは動くが本番で失敗 | プロキシ (Cloudflare、nginx) が解凍、再エンコード、末尾改行の除去をしています。ハンドラに届くバイトを確認してください。 |
| テスト ping が 401 / 署名不一致 | 検証側が `body` のみで署名している。代わりに `timestamp + "." + body` で署名してください。 |
| ヘッダーが完全に欠落 | エンドポイントが別の環境に登録されています。テストモードのエンドポイントは `environment=test` イベントのみを受け取ります。 |
