Ověřování podpisů webhooků
Každý webhook a každý požadavek nástroje, který ThunderPhone odesílá, je podepsán. Podpis jednou ověřte pomocí zde uvedeného postupu a stejnou kontrolu pak používejte na všech spuštěných koncových bodech.
Každý požadavek, který odesíláme na váš server — doručení webhooků a
volání koncových bodů nástrojů — obsahuje podpis HMAC-SHA256 v hlavičce
X-ThunderPhone-Signature. Ověření nastavte jednou správně a
stejného pomocníka použijte v každém handleru.
Algoritmus
- Přečtěte neupravené tělo požadavku — přesné bajty, které jsme vám odeslali metodou POST.
- Vypočítejte
hmac_sha256(secret, body).hexdigest(). - Porovnejte v konstantním čase s
X-ThunderPhone-Signature. (Naivní porovnání řetězců odhaluje informace o časování.)
Podepisujeme přesně ty bajty, které přenášíme, takže ověřování
neupraveného těla vždy funguje. Tyto bajty jsou zároveň kanonickou serializací JSON
datové části — klíče jsou řazeny abecedně, oddělovače jsou kompaktní
(, a : bez mezer), kódování UTF-8. Pokud váš framework zpřístupňuje pouze analyzovaný JSON,
máte k dispozici druhý, plně ekvivalentní postup:
proveďte kanonickou opětovnou serializaci a vypočítejte HMAC z ní.
# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")Upřednostněte neupravené tělo — je to o krok méně a vyhnete se zvláštnostem při opětovném převodu čísel JSON v některých jazycích.
Který tajný klíč?
| Zdroj | Tajný klíč |
|---|---|
Koncový bod webhooku (/v1/developer/webhook-endpoints) | Tajný klíč secret pro každý koncový bod (48 hexadecimálních znaků), vrácený jednou při vytvoření |
| Starší webhook s jedinou adresou URL | Tajný klíč secret pro organizaci vrácený při GET /v1/webhook |
Volání koncového bodu nástroje (přímé volání vašeho endpoint.url) | Tajný klíč webhooku na úrovni organizace (stejný jako pro starší webhook s jedinou adresou URL) — ne tajný klíč pro jednotlivý koncový bod |
Tajný klíč uložte do správce tajných klíčů nebo proměnné prostředí — nikdy jej neukládejte do repozitáře.
Referenční implementace
Všechny čtyři ověřují neupravené tělo požadavku:
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)
endZapojení specifické pro framework
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})Ověřování volání nástrojů
Když agent přímo volá některý z vašich
funkčních nástrojů (nástroj má
endpoint), požadavek kromě vámi nakonfigurovaných endpoint.headers
obsahuje dvě hlavičky ThunderPhone:
X-ThunderPhone-Call-ID— číselné ID probíhajícího hovoru.X-ThunderPhone-Signature— HMAC-SHA256 s klíčem vašeho webhookového tajemství na úrovni organizace, vytvořený nad přesnými bajty těla požadavku.
Stejný pomocník verify() funguje beze změny, se dvěma rozdíly:
- Nástroje
GET/DELETEnemají tělo. Argumenty se předávají jako parametry dotazu a podpis se vypočítá nad prázdným bajtovým řetězcem — tedyverify(b"", sig, secret)(Python) neboverify(Buffer.alloc(0), sig, secret)(Node). Řetězec dotazu nehašujte. - Organizace bez nakonfigurovaného staršího webhooku nemají tajemství organizace. V
takovém případě volání nástrojů obsahují pouze
X-ThunderPhone-Call-IDa žádnou hlavičku s podpisem. Nakonfigurujte starší webhook (PUT /v1/webhook) pro získání tajemství pro podepisování, nebo ověřujte volání nástrojů vlastní hlavičkou prostřednictvímendpoint.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)
...Odesílání nástrojů v režimu webhooku (nástroje bez endpoint, doručované
na webhook vaší organizace jako telephony.tool / web.tool) je běžný
podepsaný webhook — platí pro něj standardní postup uvedený výše. Oba tvary požadavků najdete v části
Funkční nástroje.
Běžné chyby
Opětovná serializace s výchozím formátováním
Parsování těla a jeho opětovné vypsání s výchozím nastavením vaší knihovny JSON
(mezery po , / :, klíče v pořadí vložení) vytváří
jiné bajty a naruší HMAC. Ověřujte nezpracované tělo — nebo pokud jej
musíte znovu serializovat, přesně dodržte náš kanonický formát: seřazené
klíče, kompaktní oddělovače, UTF-8.
Framework automaticky parsuje JSON
Middleware express.json() v Expressu spotřebuje datový proud těla
a přijdete o nezpracované bajty. Použijte express.raw() přímo pro cestu webhooku
nebo ukládejte nezpracované tělo do vyrovnávací paměti v předchozím middleware.
Totéž platí pro NestJS / Koa — podívejte se do jejich dokumentace k „raw body“.
Porovnání nebezpečné z hlediska časování
expected === signature v JS nebo expected == signature v
Pythonu jsou porovnání závislá na časování. Použijte příslušně crypto.timingSafeEqual
nebo hmac.compare_digest. Rozdíl ve výkonu
je nulový.
Nesprávný tajný klíč pro koncové body nástrojů
Přímá volání koncových bodů nástrojů jsou podepsána pomocí tajného klíče webhooku
na úrovni organizace (GET /v1/webhook) — nikoli tajným klíčem pro jednotlivý koncový bod
z /v1/developer/webhook-endpoints. Znovu použijte stejnou funkci verify(),
ale ujistěte se, že jí u cest nástrojů předáváte tajný klíč organizace.
Hashování řetězce dotazu u nástrojů GET/DELETE
U metod nástrojů bez těla podpis pokrývá prázdný bajtový řetězec, což zachovává jeden univerzální postup: vypočítejte HMAC z nezpracovaného těla požadavku, ať je jakékoli. Hashování adresy URL nebo řetězce dotazu nikdy nebude odpovídat.
Nevracení 401 při neshodě
Vrácení 200 při neúspěšném ověření dělá z handleru cíl pro opakované útoky. Pokud ověření selže, vždy odpovězte stavem mimo 2xx.