Open in
Проверка на подписи на уебкуки
Всяка заявка към уебкука и инструмент, изпратена от ThunderPhone, се подписва с времеви печат. Проверете я с рецептата или помощната функция на SDK тук и отхвърлете повторно изпратени заявки, по-стари от пет минути.
Всяка заявка, която изпращаме до вашия сървър (доставки чрез webhook, наследеният webhook с един 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=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4etе Unix времето в секунди, когато изпратихме заявката. Повторно изпращане се подписва отново при изпращането му със собственоt.v1е шестнадесетичният 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 |
| път и заявка | Целта на заявката от HTTP реда на заявката: пътят, след това ? и низа на заявката, когато има такъв. Без схема, хост или фрагмент. Използвайте го точно както е получен, без да го декодирате или пренареждате параметрите |
body | Необработеното тяло на заявката. Извикванията на инструменти GET и DELETE нямат тяло, затова техният подписан низ завършва с новия ред след пътя |
За проверка:
- Преди каквото и да е анализиране прочетете заглавката, метода, необработения път и заявката, както и необработеното тяло.
- Вземете
tи всякоv1от заглавката. - Отхвърлете заявката, ако
tе с повече от 300 секунди преди или след вашия часовник. - Изчислете шестнадесетичния HMAC-SHA256 на подписания низ с вашата тайна.
- Сравнете го с
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'Извикване на инструмент GET към /tools/lookup?customer_id=cust_123 в същата
секунда има празно тяло. Неговият подписан низ е
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-минутния прозорец, сравнява в константно време и приема списък
от тайни, за да можете да ги ротирате без прекъсване.
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
)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
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)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.
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}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);
},
);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), са целевият адрес точно във вида, в който са получени. get_full_path()
на Django и request.path на Flask първо декодират пътя, което дава
същия низ за обичайните пътища. Ако пътят на вашата крайна точка съдържа
процентно кодирани или резервирани знаци като %2F, ; или =, вместо това прочетете
необработения целеви адрес, който сървърът ви предоставя (например request.META["RAW_URI"] при
Gunicorn).
Коя тайна?
Една и съща тайна подписва и двата хедъра.
| Източник | Тайна |
|---|---|
Webhook крайна точка (/v1/developer/webhook-endpoints) | secret за конкретната крайна точка (48 шестнадесетични знака), върната еднократно при създаване |
| Остарял webhook с един URL | secret за организацията, върната при GET /v1/webhook |
Извикване на крайна точка за инструмент (директно извикване към вашия endpoint.url) | Webhook тайната на ниво организация (същата като за остарелия webhook с един URL) — не тайна за конкретна крайна точка |
Съхранявайте тайната във вашия мениджър за тайни или променлива на средата — никога не я записвайте в хранилището с код.
Проверка на извиквания на инструменти
Когато агентът извика директно някой от вашите
функционални инструменти (инструментът има
endpoint), заявката включва следните ThunderPhone заглавки наред с
конфигурираните от вас endpoint.headers:
X-ThunderPhone-Call-ID— числовият идентификатор на текущото обаждане.X-ThunderPhone-Signature-V2— времево маркираният подпис по-горе, свързан с вашата уебхук тайна на ниво организация.X-ThunderPhone-Signature— наследеният подпис само за тялото със същия ключ.
Има две разлики спрямо уебхуковете:
- Инструментите
GET/DELETEнямат тяло. Аргументите се предават като параметри на заявката. Подписът V2 ги обхваща чрез пътя и заявката, а полето за тяло е празно. Проверявайте с необработения низ на заявката: анализирането на параметрите и изграждането на нов низ на заявката може да промени техния ред или кодиране. Наследената заглавка подписва празния низ от байтове. - Организациите без конфигуриран наследен уебхук нямат организационна тайна. В
този случай извикванията на инструменти включват само
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 анализаторът на тялото оставя req.body като празен обект при GET,
затова подайте Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0).
Изпращането на инструменти в режим на уебхук (инструменти без endpoint, доставяни
до уебхука на вашата организация като telephony.tool / web.tool) е обикновен
подписан уебхук — приложете стандартната процедура по-горе. Вижте
Инструменти с функции за двата формата на заявки.
Стар подпис: X-ThunderPhone-Signature
Оригиналният хедър представлява шестнадесетичния HMAC-SHA256 само на суровото тяло, със същата тайна. Той не съдържа времеви печат, така че всеки, който прихване една подписана заявка, може да я повтори. Все още се изпраща при всяка заявка, докато интеграциите преминават към V2.
- Прочетете суровото тяло на заявката — точните байтове, които сме ви изпратили.
- Изчислете
hmac_sha256(secret, body).hexdigest(). - Сравнете в константно време с
X-ThunderPhone-Signature.
Подписваме точно байтовете, които предаваме. Тези байтове са също каноничната 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 числа в някои езици. Същото важи и за полето body на V2.
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Често срещани проблеми
Отклонение на часовника
5-минутният прозорец се измерва спрямо часовника на Вашия сървър. Поддържайте го синхронизиран с NTP; часовник с отклонение над 5 минути отхвърля всяка заявка.
Прокси сървър пренаписва пътя
Подписаният път е този, който сме заявили: пътят на URL адреса, който сте конфигурирали в ThunderPhone. Ако обратен прокси сървър или API шлюз премахва или добавя префикс, преди приложението Ви да види заявката, възстановете изпратения от нас път (например като добавите префикса обратно), преди да извършите проверката.
Повторно сериализиране с форматиране по подразбиране
Анализирането на тялото и повторното му сериализиране с настройките по
подразбиране на Вашата JSON библиотека (интервали след , / :, ключове в реда на вмъкване) води до
различни байтове и нарушава HMAC. Проверявайте необработеното тяло — или ако
трябва да сериализирате отново, съвпаднете точно с нашата канонична форма: сортирани
ключове, компактни разделители, UTF-8.
Рамката автоматично анализира JSON
Междинният обработчик express.json() на Express консумира потока на тялото
и губите необработените байтове. Използвайте express.raw() конкретно за
маршрута на уебхука или буферирайте необработеното тяло в предходен междинен
обработчик. Същото важи за NestJS / Koa — проверете документацията им за
„необработено тяло“.
Небезопасно спрямо времето сравнение
expected === signature в JS или expected == signature в
Python са сравнения с променливо време. Използвайте crypto.timingSafeEqual
или съответно hmac.compare_digest. Разликата в производителността
е нулева.
Грешна тайна за крайни точки на инструменти
Директните извиквания към крайни точки на инструменти се подписват с тайната за уебхук
на ниво организация (GET /v1/webhook) — не с тайна за отделна крайна точка от
/v1/developer/webhook-endpoints. Използвайте повторно същия помощен метод,
но се уверете, че му подавате тайната на организацията за маршрутите на инструментите.
Низове на заявката при GET/DELETE инструменти
V2 подписва низа на заявката точно както е изпратен. Наследената заглавка подписва празното тяло и никога низа на заявката, така че хеширането на URL адреса за наследената заглавка никога няма да съвпадне.
Дубликати в рамките на прозореца
Времевият отпечатък спира повторни изпращания, по-стари от 5 минути, но не и второ копие
в рамките на този прозорец. Доставките към крайни точки са поне веднъж, така че
може да пристигне и повторен опит за същото събитие; премахвайте дубликатите по
event_id на обвивката.
Невръщане на 401 при несъответствие
Връщането на 200 при неуспешна проверка прави обработчика цел за атаки с повторно изпращане. Винаги връщайте отговор, различен от 2xx, ако проверката е неуспешна.
Следващи стъпки
Семантика на доставката, повторни опити, IP адреси на източника.
Управлявайте множество URL адреси, сменяйте секретни ключове.
Двата начина за извикване на инструменти и форматите на заявките им.
Създайте цялостна интеграция с инструменти от край до край.