Перевірка підписів вебхуків
Кожен вебхук і запит інструмента, який надсилає ThunderPhone, підписано. Один раз перевірте підпис за наведеним тут рецептом, а потім використовуйте ту саму перевірку на кожній кінцевій точці, яку ви запускаєте.
Кожен запит, який ми надсилаємо на ваш сервер — доставки вебхуків і
виклики кінцевих точок інструментів — містить підпис HMAC-SHA256 у заголовку
X-ThunderPhone-Signature. Один раз правильно налаштуйте перевірку та
підключіть той самий допоміжний метод до кожного обробника.
Алгоритм
- Прочитайте необроблене тіло запиту — точні байти, які ми надіслали вам методом POST.
- Обчисліть
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 у деяких мовах.
Який секрет?
| Джерело | Секрет |
|---|---|
Кінцева точка вебхука (/v1/developer/webhook-endpoints) | secret для кожної кінцевої точки (48 шістнадцяткових символів), що повертається один раз під час створення |
| Застарілий вебхук з однією URL-адресою | secret для кожної організації, що повертається через GET /v1/webhook |
Виклик кінцевої точки інструменту (прямий виклик вашого 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), запит містить два заголовки ThunderPhone разом із
налаштованими вами endpoint.headers:
X-ThunderPhone-Call-ID— числовий ідентифікатор активного дзвінка.X-ThunderPhone-Signature— HMAC-SHA256 із ключем у вигляді вашого секрету вебхука на рівні організації, обчислений за точними байтами тіла запиту.
Той самий допоміжний засіб verify() працює без змін, але є два нюанси:
- Інструменти
GET/DELETEне мають тіла. Аргументи передаються як параметри запиту, а підпис обчислюється для порожнього рядка байтів — тобтоverify(b"", sig, secret)(Python) абоverify(Buffer.alloc(0), sig, secret)(Node). Не хешуйте рядок запиту. - Організації без налаштованого застарілого вебхука не мають секрету організації. У
такому разі виклики інструментів містять лише
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 — перегляньте їхню документацію щодо «необробленого тіла запиту».
Порівняння, небезпечне щодо часу виконання
expected === signature у JS або expected == signature у
Python — це порівняння зі змінним часом виконання. Використовуйте crypto.timingSafeEqual
або hmac.compare_digest відповідно. Різниця в продуктивності
відсутня.
Неправильний секрет для кінцевих точок інструментів
Прямі виклики кінцевих точок інструментів підписуються секретом вебхука
на рівні організації (GET /v1/webhook) — а не секретом окремої кінцевої точки
з /v1/developer/webhook-endpoints. Повторно використовуйте ту саму функцію verify(),
але переконайтеся, що передаєте їй секрет організації в маршрутах інструментів.
Хешування рядка запиту для інструментів GET/DELETE
Для методів інструментів без тіла запиту підпис охоплює порожній рядок байтів, зберігаючи єдиний універсальний підхід: обчислюйте HMAC для необробленого тіла запиту, яким би воно не було. Хешування URL або рядка запиту ніколи не збігатиметься.
Не повертати 401 у разі невідповідності
Повернення 200 у разі невдалої перевірки робить обробник ціллю для повторних атак. Завжди повертайте статус не 2xx, якщо перевірка не пройшла.
Наступні кроки
Семантика доставки, повторні спроби, IP-адреси джерел.
Керуйте кількома URL-адресами, змінюйте секрети.
Два шляхи виклику інструментів і форми їхніх запитів.
Створіть повну інтеграцію на основі інструментів від початку до кінця.