웹훅 서명 확인
ThunderPhone이 전송하는 모든 웹훅 및 도구 요청에는 서명이 포함됩니다. 여기의 절차에 따라 서명을 한 번 확인한 후, 운영하는 모든 엔드포인트에서 동일한 검사를 재사용합니다.
서버로 전송하는 모든 요청(웹훅 전송 및 도구 엔드포인트 호출)에는 X-ThunderPhone-Signature 헤더에 HMAC-SHA256 서명이 포함됩니다. 검증을 한 번만 올바르게 구현하고 모든 핸들러에 동일한 헬퍼를 연결합니다.
알고리즘
- 원본 요청 본문, 즉 ThunderPhone이 POST한 정확한 바이트를 읽습니다.
hmac_sha256(secret, body).hexdigest()를 계산합니다.X-ThunderPhone-Signature와 상수 시간으로 비교합니다. (단순 문자열 비교는 타이밍 정보를 노출합니다.)
ThunderPhone은 전송하는 정확한 바이트에 서명하므로 원본 본문을 검증하면 항상 작동합니다. 해당 바이트는 페이로드의 표준 JSON 직렬화이기도 합니다. 즉, 키는 알파벳순으로 정렬되고, 구분 기호는 공백 없는(, 및 :) 형식이며, UTF-8을 사용합니다. 프레임워크가 파싱된 JSON만 제공하는 경우에도 완전히 동등한 두 번째 방법을 사용할 수 있습니다. 표준 형식으로 다시 직렬화한 뒤 HMAC을 계산합니다.
# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")원본 본문을 사용하는 것이 좋습니다. 단계가 하나 줄어들고 일부 언어에서 발생하는 JSON 숫자 왕복 변환 특성의 영향을 받지 않습니다.
어떤 시크릿을 사용하나요?
| 소스 | 시크릿 |
|---|---|
웹훅 엔드포인트 (/v1/developer/webhook-endpoints) | 생성 시 한 번 반환되는 엔드포인트별 secret(16진수 문자 48개) |
| 레거시 단일 URL 웹훅 | GET /v1/webhook에서 반환되는 조직별 secret |
도구 엔드포인트 호출 (사용자의 endpoint.url로 직접 호출) | 조직 수준 웹훅 시크릿(레거시 단일 URL 웹훅과 동일한 시크릿)이며, 엔드포인트별 시크릿이 아닙니다. |
시크릿은 시크릿 관리자 또는 환경 변수에 저장하고, 절대 커밋하지 마세요.
참조 구현
네 구현 모두 원본 요청 본문을 검증합니다.
import hashlib
import hmac
def verify(body: bytes, signature: str, secret: str) -> bool:
"""Constant-time HMAC-SHA256 verification."""
expected = hmac.new(
secret.encode("utf-8"),
body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature or "")import crypto from "node:crypto";
export function verify(body, signature, secret) {
const expected = crypto
.createHmac("sha256", secret)
.update(body)
.digest("hex");
if (!signature || expected.length !== signature.length) return false;
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature),
);
}package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
)
func Verify(body []byte, signature, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}require "openssl"
def verify(body, signature, secret)
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, body)
Rack::Utils.secure_compare(expected, signature.to_s)
end프레임워크별 연결
from fastapi import FastAPI, HTTPException, Request
app = FastAPI()
@app.post("/thunderphone-webhook")
async def hook(request: Request):
body = await request.body() # raw bytes, NOT request.json()
sig = request.headers.get("X-ThunderPhone-Signature", "")
if not verify(body, sig, SECRET):
raise HTTPException(status_code=401)
import json
event = json.loads(body)
# … dispatch on event["type"] …
return {"ok": True}import express from "express";
const app = express();
app.post(
"/thunderphone-webhook",
// IMPORTANT: parse as raw; do NOT use express.json() here.
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.header("X-ThunderPhone-Signature") || "";
if (!verify(req.body, sig, process.env.WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
// … dispatch on event.type …
res.sendStatus(204);
},
);import json
from django.http import JsonResponse, HttpResponseForbidden
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
@csrf_exempt
@require_POST
def hook(request):
body = request.body # raw bytes
sig = request.headers.get("X-ThunderPhone-Signature", "")
if not verify(body, sig, SECRET):
return HttpResponseForbidden("invalid signature")
event = json.loads(body)
# … dispatch on event["type"] …
return JsonResponse({"ok": True})도구 호출 검증
에이전트가 함수 도구 중 하나를 직접 호출하면
(도구에 endpoint가 있는 경우), 요청에는 구성한
endpoint.headers와 함께 두 개의 ThunderPhone 헤더가 포함됩니다.
X-ThunderPhone-Call-ID— 진행 중인 통화의 숫자 ID입니다.X-ThunderPhone-Signature— 정확한 요청 본문 바이트에 대해 조직 수준 웹훅 시크릿을 키로 사용하는 HMAC-SHA256입니다.
동일한 verify() 헬퍼를 그대로 사용할 수 있지만, 다음 두 가지 차이점이 있습니다.
GET/DELETE도구에는 본문이 없습니다. 인수는 쿼리 매개변수로 전달되며, 서명은 빈 바이트 문자열에 대해 계산됩니다. 따라서 Python에서는verify(b"", sig, secret), Node에서는verify(Buffer.alloc(0), sig, secret)를 사용합니다. 쿼리 문자열을 해시하지 마세요.- 기존 웹훅이 구성되지 않은 조직에는 조직 시크릿이 없습니다.
이 경우 도구 호출에는
X-ThunderPhone-Call-ID만 포함되고 서명 헤더는 포함되지 않습니다. 서명 시크릿을 받으려면 기존 웹훅 (PUT /v1/webhook)을 구성하거나,endpoint.headers를 통해 자체 헤더로 도구 호출을 인증하세요.
@app.post("/tools/search-appointments")
async def tool(request: Request):
body = await request.body() # b"" for GET/DELETE tools
sig = request.headers.get("X-ThunderPhone-Signature", "")
call_id = request.headers.get("X-ThunderPhone-Call-ID", "")
if not verify(body, sig, ORG_WEBHOOK_SECRET):
raise HTTPException(status_code=401)
args = json.loads(body)
...웹훅 모드 도구 디스패치(endpoint가 없는 도구이며,
telephony.tool / web.tool로 조직 웹훅에 전달됨)는 일반적인
서명 웹훅입니다. 위의 표준 방식을 적용하세요. 두 요청 형식은
함수 도구를 참조하세요.
일반적인 문제
기본 형식으로 다시 직렬화
본문을 파싱한 후 JSON 라이브러리의 기본값(, / : 뒤 공백, 삽입 순서 키)으로
다시 덤프하면 바이트가 달라져 HMAC이 실패합니다. 원본 본문을 검증하세요. 다시
직렬화해야 한다면 정렬된 키, 압축 구분 기호, UTF-8이라는 정규 형식과 정확히 일치시켜야 합니다.
프레임워크가 JSON을 자동 파싱
Express의 express.json() 미들웨어는 본문 스트림을 소비하므로 원본 바이트를 잃게 됩니다.
웹훅 라우트에는 express.raw()를 사용하거나 사전 미들웨어에서 원본 본문을 버퍼링하세요.
NestJS / Koa도 마찬가지이므로 "raw body" 문서를 확인하세요.
타이밍 공격에 안전하지 않은 비교
JS의 expected === signature 또는 Python의 expected == signature는
실행 시간이 달라지는 비교입니다. 각각 crypto.timingSafeEqual 또는
hmac.compare_digest를 사용하세요. 성능 차이는 없습니다.
도구 엔드포인트에 잘못된 시크릿 사용
직접 도구 엔드포인트 호출은 /v1/developer/webhook-endpoints의 엔드포인트별 시크릿이 아니라
조직 수준 웹훅 시크릿 (GET /v1/webhook)으로 서명됩니다. 동일한 verify()
함수를 재사용하되, 도구 라우트에는 반드시 조직 시크릿을 전달하세요.
GET/DELETE 도구에서 쿼리 문자열 해싱
본문이 없는 도구 메서드의 서명은 빈 바이트 문자열을 포함하므로, 요청 본문이 무엇이든 원본 요청 본문에 HMAC을 적용하는 단일 방식을 유지할 수 있습니다. URL 또는 쿼리 문자열을 해싱하면 절대 일치하지 않습니다.
불일치 시 401을 반환하지 않음
검증 실패 시 200을 반환하면 핸들러가 재전송 공격의 대상이 됩니다. 검증에 실패하면 항상 2xx가 아닌 응답을 반환하세요.