ThunderPhone 2.0을 출시했습니다.셀프서비스로, 분당 2¢부터.출시 소식 보기

Operations

웹훅 서명 확인

ThunderPhone이 전송하는 모든 웹훅 및 도구 요청에는 타임스탬프가 포함된 서명이 적용됩니다. 여기의 레시피 또는 SDK 헬퍼로 이를 확인하고, 5분보다 오래된 재전송 요청은 거부합니다.

서버로 전송하는 모든 요청(웹훅 전송, 레거시 단일 URL 웹훅 및 도구 엔드포인트 호출)에는 두 개의 서명 헤더가 포함됩니다. 둘 다 동일한 시크릿을 사용하는 HMAC-SHA256입니다.

헤더서명 대상재전송 방지
X-ThunderPhone-Signature-V2 (권장)타임스탬프, 메서드, 경로, 쿼리 및 본문5분보다 오래된 요청 거부
X-ThunderPhone-Signature본문만없음: 캡처된 요청은 영구적으로 검증됨

새 통합에서는 X-ThunderPhone-Signature-V2를 검증하고, 기존 통합도 이를 사용하도록 전환합니다. X-ThunderPhone-Signature는 변경 없이 모든 요청에 계속 전송되므로, 이를 기반으로 구축된 검증기는 계속 작동합니다. 전송을 중단하기 전에 날짜를 공지합니다.

서명 V2

X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e
  • t는 요청을 전송한 시점의 Unix 시간(초)입니다. 재시도된 전송은 전송 시점에 다시 서명되며, 자체 t 값을 갖습니다.
  • v1은 아래 서명 문자열의 16진수 HMAC-SHA256이며, 고객님의 시크릿을 키로 사용합니다.

값은 쉼표로 구분된 key=value 쌍 목록입니다. 알 수 없는 키는 무시하고, v1 중 하나라도 일치하면 요청을 수락합니다. 향후 변경 시 두 번째 서명을 추가할 수 있습니다.

서명 문자열

마지막 필드를 제외한 각 필드 뒤에는 줄바꿈 하나(\n, 바이트 0x0A)가 있습니다.

<t>\n<METHOD>\n<path and query>\n<body>
필드값
t헤더의 t 값
METHOD대문자 HTTP 메서드: POST, GET, PUT, PATCH 또는 DELETE
path and queryHTTP 요청 줄의 요청 대상입니다. 경로 뒤에 쿼리 문자열이 있는 경우 ?와 쿼리 문자열이 이어집니다. 스킴, 호스트, 프래그먼트는 포함하지 않습니다. 디코딩하거나 매개변수 순서를 변경하지 말고, 수신한 그대로 사용합니다
body원시 요청 본문입니다. GET 및 DELETE 도구 호출에는 본문이 없으므로, 서명 문자열은 경로 뒤의 줄바꿈으로 끝납니다

검증하려면 다음을 수행합니다.

  1. 파싱하기 전에 헤더, 메서드, 원시 경로 및 쿼리, 원시 본문을 읽습니다.
  2. 헤더에서 t와 모든 v1을 가져옵니다.
  3. t가 현재 시간보다 이전 또는 이후로 300초를 초과하면 요청을 거부합니다.
  4. 시크릿을 사용하여 서명 문자열의 16진수 HMAC-SHA256을 계산합니다.
  5. v1과 상수 시간으로 비교합니다. 일치하는 값이 없으면 401로 응답합니다.

동작 예시

시크릿은 whsec_example_4c1f2a9b입니다. 다음 요청을 t = 1767225600(2026-01-01 00:00:00 UTC)에 전송합니다.

POST /tools/lookup?region=us HTTP/1.1
Host: api.example.com
Content-Type: application/json
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e
 
{"customer_id":"cust_123"}

서명 문자열은 다음 네 줄이며, 본문 뒤에는 줄바꿈이 없습니다.

1767225600
POST
/tools/lookup?region=us
{"customer_id":"cust_123"}

HMAC-SHA256은 03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e입니다. 이를 사용해 구현을 확인하거나 openssl로 재현할 수 있습니다.

printf '1767225600\nPOST\n/tools/lookup?region=us\n{"customer_id":"cust_123"}' \
  | openssl dgst -sha256 -hmac 'whsec_example_4c1f2a9b'

같은 초에 /tools/lookup?customer_id=cust_123으로 보내는 GET 도구 호출은 빈 본문을 갖습니다. 서명 문자열은 1767225600\nGET\n/tools/lookup?customer_id=cust_123\n이며 서명은 3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280입니다.

SDK 사용

Python 및 TypeScript SDK에는 도우미가 포함되어 있습니다(thunderphone 0.2.0 이상 또는 @thunderphone/sdk 0.2.0 이상). 이 도우미는 5분 허용 범위를 확인하고, 상수 시간으로 비교하며, 다운타임 없이 교체할 수 있도록 시크릿 목록을 허용합니다.

Python
from thunderphone import verify_signature
 
ok = verify_signature(
    header,            # the X-ThunderPhone-Signature-V2 value
    secret=SECRET,     # or [NEW_SECRET, OLD_SECRET] while rotating
    method=method,     # "POST"
    path=path,         # "/tools/lookup?region=us", exactly as received
    body=raw_body,     # bytes, before any JSON parsing
    tolerance=300,     # optional; seconds
)
Node.js
import { verifySignature } from "@thunderphone/sdk";
 
const ok = verifySignature({
  header,               // the X-ThunderPhone-Signature-V2 value
  secret: SECRET,       // or [NEW_SECRET, OLD_SECRET] while rotating
  method,               // "POST"
  path,                 // "/tools/lookup?region=us", exactly as received
  body: rawBody,        // Buffer or string, before any JSON parsing
  toleranceSeconds: 300, // optional
});

SDK 없이

Python
import hashlib
import hmac
import time
 
TOLERANCE_SECONDS = 300
 
 
def verify_v2(header: str, secret: str, method: str, path: str, body: bytes) -> bool:
    """path is the path plus ?query exactly as received; body is the raw body."""
    timestamp, signatures = None, []
    for item in (header or "").split(","):
        key, _, value = item.strip().partition("=")
        if key == "t" and value.isdigit() and len(value) <= 15:
            timestamp = int(value)
        elif key == "v1":
            signatures.append(value)
    if timestamp is None or abs(time.time() - timestamp) > TOLERANCE_SECONDS:
        return False
    signed = f"{timestamp}\n{method.upper()}\n{path}\n".encode() + body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected.encode(), s.encode()) for s in signatures)
Node.js
import crypto from "node:crypto";
 
const TOLERANCE_SECONDS = 300;
 
// path is the path plus ?query exactly as received; body is the raw Buffer.
export function verifyV2(header, secret, method, path, body) {
  let timestamp;
  const signatures = [];
  for (const item of (header || "").split(",")) {
    const [key, value = ""] = item.trim().split("=", 2);
    if (key === "t" && /^\d+$/.test(value)) timestamp = Number(value);
    else if (key === "v1") signatures.push(value);
  }
  if (timestamp === undefined) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
  const expected = Buffer.from(
    crypto
      .createHmac("sha256", secret)
      .update(`${timestamp}\n${method.toUpperCase()}\n${path}\n`)
      .update(body)
      .digest("hex"),
  );
  return signatures.some((sig) => {
    const candidate = Buffer.from(sig);
    return (
      candidate.length === expected.length &&
      crypto.timingSafeEqual(candidate, expected)
    );
  });
}

프레임워크 연결

각 예시는 원시 경로와 쿼리, 원시 본문을 읽은 다음 SDK 헬퍼를 호출합니다.

FastAPI
import json
 
from fastapi import FastAPI, HTTPException, Request
from thunderphone import verify_signature
 
app = FastAPI()
 
 
def raw_path_and_query(request: Request) -> str:
    path = (request.scope.get("raw_path") or request.url.path.encode()).decode("latin-1")
    query = request.scope.get("query_string", b"").decode("latin-1")
    return f"{path}?{query}" if query else path
 
 
@app.post("/thunderphone-webhook")
async def hook(request: Request):
    body = await request.body()  # raw bytes, NOT request.json()
    if not verify_signature(
        request.headers.get("X-ThunderPhone-Signature-V2"),
        secret=SECRET,
        method=request.method,
        path=raw_path_and_query(request),
        body=body,
    ):
        raise HTTPException(status_code=401)
    event = json.loads(body)
    # … dispatch on event["type"] …
    return {"ok": True}
Express
import express from "express";
import { verifySignature } from "@thunderphone/sdk";
 
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 ok = verifySignature({
      header: req.header("X-ThunderPhone-Signature-V2"),
      secret: process.env.WEBHOOK_SECRET,
      method: req.method,
      path: req.originalUrl, // path and query as received
      body: req.body,
    });
    if (!ok) return res.sendStatus(401);
    const event = JSON.parse(req.body.toString("utf8"));
    // … dispatch on event.type …
    res.sendStatus(204);
  },
);
Django
import json
 
from django.http import HttpResponseForbidden, JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from thunderphone import verify_signature
 
 
@csrf_exempt
@require_POST
def hook(request):
    if not verify_signature(
        request.headers.get("X-ThunderPhone-Signature-V2"),
        secret=SECRET,
        method=request.method,
        path=request.get_full_path(),
        body=request.body,  # raw bytes
    ):
        return HttpResponseForbidden("invalid signature")
    event = json.loads(request.body)
    # … dispatch on event["type"] …
    return JsonResponse({"ok": True})

req.originalUrl(Express)과 ASGI의 raw_path, query_string (FastAPI)은 수신된 그대로의 요청 대상입니다. Django의 get_full_path()와 Flask의 request.path는 먼저 경로를 디코딩하므로, 일반 경로에서는 동일한 문자열을 반환합니다. 엔드포인트 경로에 %2F, ;, = 같은 퍼센트 인코딩 문자나 예약 문자가 포함되어 있으면, 대신 서버가 제공하는 원시 요청 대상을 읽으십시오(예: Gunicorn의 request.META["RAW_URI"]).

어떤 시크릿을 사용하나요?

동일한 시크릿이 두 헤더에 모두 서명합니다.

소스시크릿
웹훅 엔드포인트 (/v1/developer/webhook-endpoints)생성 시 한 번 반환되는 엔드포인트별 secret(16진수 문자 48개)
레거시 단일 URL 웹훅GET /v1/webhook에서 반환되는 조직별 secret
도구 엔드포인트 호출 (사용자의 endpoint.url로 직접 호출)조직 수준 웹훅 시크릿(레거시 단일 URL 웹훅과 동일한 시크릿)이며, 엔드포인트별 시크릿이 아닙니다.

시크릿은 시크릿 관리자 또는 환경 변수에 저장하고, 절대 커밋하지 마세요.

도구 호출 검증

에이전트가 함수 도구 중 하나를 직접 호출하는 경우(도구에 endpoint가 있는 경우), 요청에는 구성한 endpoint.headers와 함께 다음 ThunderPhone 헤더가 포함됩니다.

  • X-ThunderPhone-Call-ID — 진행 중인 통화의 숫자 ID입니다.
  • X-ThunderPhone-Signature-V2 — 조직 수준 웹훅 시크릿으로 키 지정된 위의 타임스탬프 포함 서명입니다.
  • X-ThunderPhone-Signature — 동일한 키를 사용하는 레거시 본문 전용 서명입니다.

웹훅과 다른 점은 두 가지입니다.

  1. GET / DELETE 도구에는 본문이 없습니다. 인수는 쿼리 매개변수로 전달됩니다. V2 서명은 경로와 쿼리를 통해 이를 포함하며, 본문 필드는 비어 있습니다. 원시 쿼리 문자열로 검증합니다. 매개변수를 파싱하고 새 쿼리 문자열을 만들면 순서나 인코딩이 변경될 수 있습니다. 레거시 헤더는 빈 바이트 문자열에 서명합니다.
  2. 레거시 웹훅이 구성되지 않은 조직에는 조직 시크릿이 없습니다. 이 경우 도구 호출에는 X-ThunderPhone-Call-ID만 포함되며 서명 헤더는 없습니다. 서명 시크릿을 얻으려면 레거시 웹훅(PUT /v1/webhook)을 구성하거나, endpoint.headers를 통해 자체 헤더로 도구 호출을 인증합니다.
@app.api_route("/tools/search-appointments", methods=["GET", "POST"])
async def tool(request: Request):
    body = await request.body()  # b"" for GET/DELETE tools
    if not verify_signature(
        request.headers.get("X-ThunderPhone-Signature-V2"),
        secret=ORG_WEBHOOK_SECRET,
        method=request.method,
        path=raw_path_and_query(request),
        body=body,
    ):
        raise HTTPException(status_code=401)
    call_id = request.headers.get("X-ThunderPhone-Call-ID", "")
    args = json.loads(body) if body else dict(request.query_params)
    ...

Express에서는 본문 파서가 GET 요청의 req.body를 빈 객체로 남겨 두므로, Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0)를 전달합니다.

웹훅 모드 도구 디스패치(endpoint가 없는 도구이며, telephony.tool / web.tool로 조직 웹훅에 전달됨)는 일반적인 서명 웹훅입니다. 위의 표준 방식을 적용하세요. 두 요청 형식은 함수 도구를 참조하세요.

레거시 서명: X-ThunderPhone-Signature

원래 헤더는 동일한 시크릿을 사용하여 원본 본문만 대상으로 계산한 16진수 HMAC-SHA256입니다. 타임스탬프가 없으므로 서명된 요청 하나를 캡처한 사람은 누구나 이를 재전송할 수 있습니다. 통합이 V2로 전환되는 동안에도 모든 요청에 계속 전송됩니다.

  1. 원본 요청 본문, 즉 ThunderPhone이 전송한 정확한 바이트를 읽습니다.
  2. hmac_sha256(secret, body).hexdigest()를 계산합니다.
  3. 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 숫자 왕복 변환의 특이점에도 영향을 받지 않습니다. V2의 본문 필드에도 동일하게 적용됩니다.

Python
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 "")
Node.js
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),
  );
}
Go
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))
}
Ruby
require "openssl"
 
def verify(body, signature, secret)
  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, body)
  Rack::Utils.secure_compare(expected, signature.to_s)
end

일반적인 문제

시계 오차

5분 윈도는 서버의 시계를 기준으로 측정됩니다. NTP와 동기화 상태를 유지하십시오. 5분 이상 차이 나는 시계는 모든 요청을 거부합니다.

프록시가 경로를 다시 작성하는 경우

서명된 경로는 ThunderPhone에서 요청한 경로, 즉 구성한 URL의 경로입니다. 애플리케이션이 요청을 확인하기 전에 리버스 프록시 또는 API 게이트웨이가 접두사를 제거하거나 추가하는 경우, 검증하기 전에 전송된 경로를 다시 구성하십시오(예: 접두사를 다시 추가).

기본 형식으로 다시 직렬화하는 경우

본문을 파싱한 후 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를 사용하세요. 성능 차이는 없습니다.

도구 엔드포인트에 잘못된 시크릿을 사용하는 경우

직접 도구 엔드포인트 호출은 조직 수준 웹훅 시크릿 (GET /v1/webhook)으로 서명됩니다. /v1/developer/webhook-endpoints의 엔드포인트별 시크릿으로 서명되는 것이 아닙니다. 동일한 헬퍼를 재사용하되, 도구 경로에서는 조직 시크릿을 전달해야 합니다.

GET/DELETE 도구의 쿼리 문자열

V2는 전송된 쿼리 문자열을 정확히 그대로 서명합니다. 레거시 헤더는 빈 본문에 서명하며 쿼리 문자열에는 절대 서명하지 않으므로, 레거시 헤더용으로 URL을 해싱해도 일치하지 않습니다.

윈도 내 중복

타임스탬프는 5분보다 오래된 재생 공격을 차단할 뿐, 해당 윈도 내의 두 번째 사본은 차단하지 않습니다. 엔드포인트 전달은 최소 한 번 수행되므로 동일한 이벤트의 재시도가 도착할 수도 있습니다. 엔벌로프의 event_id를 기준으로 중복을 제거하십시오.

불일치 시 401을 반환하지 않음

검증 실패 시 200을 반환하면 핸들러가 재전송 공격의 대상이 됩니다. 검증에 실패하면 항상 2xx가 아닌 응답을 반환하세요.


다음 단계