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

# 서명 검증

모든 웹훅 전달은 함께 사용되는 두 개의 헤더를 포함합니다.

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

`sha256=` 뒤의 hex 문자열은 `HMAC-SHA256(secret, timestamp + "." + raw_body)`
입니다. 점은 리터럴 바이트이며, 타임스탬프는 ASCII로 된 유닉스 초입니다.

## 검증이 필요한 이유

웹훅 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에서는 웹훅 경로에만 `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 헤더의 값 (유닉스 초)
  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)와
// 일치하고 타임스탬프가 허용 윈도우 내에 있을 때만 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: 유닉스 초."""
    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. 새 시크릿이 생성되어 **정확히 한 번** 표시됩니다. 다이얼로그를 닫기
   전에 복사하세요.
3. env 변수를 업데이트하고 24시간 이내에 검증자를 재배포하세요.

### Grace 윈도우 (이중 서명)

로테이션 후 **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`와 일치합니다. 어느 쪽
헤더가 통과되어도 충분합니다 — 핸들러는 배포를 보류하지 않고 마이그레이션
중에 전달을 수락할 수 있습니다.

Grace 윈도우가 닫힌 후에는 `X-Signature`만 전송됩니다. 이전 시크릿은
더 이상 수락되지 않으며, 여전히 이것으로 구성된 검증자는 전달 거부를
시작합니다 — 따라서 24시간 예산 내에 롤아웃을 완료하세요.

### 권장 수신자 패턴

```ts
// 로테이션 grace 윈도우 동안 어느 쪽 서명도 수락.
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));
```

엔드포인트의 grace 윈도우가 만료되고 env에서 `PREV_SECRET`을 제거한 즉시
`X-Signature-Prev` 분기를 제거할 수 있습니다.

### 긴급 폐기

시크릿이 공개적으로 유출되어 이전 시크릿을 즉시 무효화해야 하는 경우 —
즉, 24시간 오버랩이 알려진 잘못된 키를 유지하기를 원하지 않는 경우 — 두
번 로테이션하세요. 첫 로테이션은 유출된 시크릿을 prev 슬롯으로 이동시킵니다.
두 번째 로테이션은 그것을 prev 슬롯에서 밀어내(여전히 새 키로 대체)
유출된 값이 더 이상 수락되지 않게 합니다.

## 와이어링 테스트

대시보드에서 **Developers → Webhooks**를 열고 검증하려는 엔드포인트에서
**Send Test**를 클릭하세요. 저희는 합성 envelope에 서명하여 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..."
  }
}
```

테스트 핑은 프로덕션 전달과 동일한 서명 방식을 사용하므로, 이 버튼의
녹색 체크는 검증자가 실제 이벤트도 수락함을 확인합니다. 테스트 핑은
재시도되지 않습니다. 재시도를 확인하려면 관련 API 흐름을 통해 실제 이벤트를
트리거하세요.

## 일반적인 실패

| 증상 | 가능한 원인 |
| --- | --- |
| 개발 환경에서 항상 false 반환 | HMAC 이전에 본문이 JSON 파싱됨. 먼저 원본 바이트를 읽으세요. |
| 어제는 작동했지만 오늘은 실패 | 시크릿을 로테이션했지만 이 서버의 env 변수에 여전히 이전 것이 있음. 새 시크릿으로 재배포하세요. |
| 이전 이벤트는 실패하고 새 이벤트는 작동 | 로테이션 전에 전달이 큐잉됨. 서명이 이전 시크릿을 사용하며 검증자는 더 이상 수락하지 않습니다. 재시도가 떨어지기를 기다리거나 대시보드를 통해 재생하세요. |
| 타임스탬프 비교에서 1 차이 | 유닉스 초를 유닉스 초와 비교하는지 확인. JS의 `Date.now()`는 **밀리초**입니다 — 1000으로 나누세요. |
| 로컬에서는 작동, 프로덕션에서는 실패 | 프록시(Cloudflare, nginx)가 압축 해제, 재인코딩 또는 후행 개행 제거 중입니다. 핸들러가 보는 바이트를 검사하세요. |
| 테스트 핑이 401 / 서명 불일치 | 검증자가 `body`만 서명 중입니다. 대신 `timestamp + "." + body`를 서명하세요. |
| 헤더가 완전히 누락 | 엔드포인트가 다른 환경에 등록됨. 테스트 모드 엔드포인트는 `environment=test` 이벤트만 받습니다. |
