보안 검증
수신한 Webhook 요청이 실제로 dmolto에서 전송되었는지 서명을 검증하는 방법을 설명합니다.
모든 Webhook 요청에는 Dmolto-Timestamp, Dmolto-Event-Id, Dmolto-Signature 헤더가 포함됩니다. Dmolto-Signature 값은 v1=서명 형식입니다. 등록형 Webhook은 생성 시 발급된 webhook_secret, 요청별 callback_url은 Bank API 키에서 파생한 Secret으로 검증합니다.
요청별 callback_url Secret
요청별 callback_url은 별도 Secret을 응답하지 않습니다. 아래와 같이 Bank API 키의 SHA-256 해시를 HMAC 키로 사용해 한 번 파생한 값을 안전한 환경 변수에 저장하세요. 원본 API 키를 Webhook 처리 코드 곳곳에 전달할 필요가 없습니다.
Node.js
import crypto from "node:crypto"
function deriveRequestCallbackSecret(apiKey: string) {
const apiKeyHash = crypto.createHash("sha256").update(apiKey).digest()
const derived = crypto
.createHmac("sha256", apiKeyHash)
.update("dmolto:request-callback-secret:v1")
.digest("base64url")
return "whsec_" + derived
}검증 절차
{timestamp}.{event_id}.{raw_body} 형태의 바이트열을 해당 Webhook Secret으로 HMAC-SHA256 서명한 뒤, 헤더의 Dmolto-Signature 값과 상수 시간 비교로 일치 여부를 확인합니다.
Node.js
import crypto from "node:crypto"
function headerValue(value: string | string[] | undefined) {
return Array.isArray(value) ? value[0] : value ?? ""
}
function verifySignature(rawBody: Buffer, headers: Record<string, string | string[] | undefined>, secret: string) {
const timestamp = headerValue(headers["dmolto-timestamp"])
const eventId = headerValue(headers["dmolto-event-id"])
const signature = headerValue(headers["dmolto-signature"])
if (!timestamp || !eventId || !signature.startsWith("v1=")) {
return false
}
const signedPayload = Buffer.concat([Buffer.from(timestamp + "." + eventId + "."), rawBody])
const expected = Buffer.from(
"v1=" + crypto.createHmac("sha256", secret).update(signedPayload).digest("hex"),
)
const actual = Buffer.from(signature)
const age = Math.abs(Date.now() / 1000 - Number(timestamp))
if (age > 300 || expected.length !== actual.length) {
return false
}
return crypto.timingSafeEqual(expected, actual)
}Python 예시
Python
import hashlib
import hmac
import time
def verify_signature(raw_body: bytes, timestamp: str, event_id: str, signature: str, secret: str) -> bool:
if not timestamp or not event_id or not signature.startswith("v1="):
return False
signed_payload = timestamp.encode() + b"." + event_id.encode() + b"." + raw_body
expected = "v1=" + hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
if abs(time.time() - int(timestamp)) > 300:
return False
return hmac.compare_digest(expected, signature)raw body를 사용하세요
서명 검증에는 JSON.parse로 다시 직렬화한 본문이 아니라, 요청에서 받은 원본(raw) 바이트를 그대로 사용해야 합니다. 프레임워크의 body parser가 자동으로 파싱하는 경우 raw body 미들웨어를 별도로 설정하세요.