Overenie podpisov webhookov
Každá požiadavka, ktorú odosielame na váš server — doručenia webhookov a
volania koncových bodov nástrojov — obsahuje podpis HMAC-SHA256 v hlavičke
X-ThunderPhone-Signature. Overenie implementujte správne raz a
rovnakého pomocníka použite v každom obslužnom programe.
Algoritmus
- Prečítajte nespracované telo požiadavky — presné bajty, ktoré sme vám odoslali metódou POST.
- Vypočítajte
hmac_sha256(secret, body).hexdigest(). - Porovnajte v konštantnom čase s
X-ThunderPhone-Signature. (Naivné porovnanie reťazcov prezrádza informácie o časovaní.)
Podpisujeme presne tie bajty, ktoré prenášame, takže overenie nespracovaného tela
vždy funguje. Tieto bajty sú zároveň kanonickou serializáciou JSON
obsahu — kľúče zoradené abecedne, kompaktné oddeľovače
(, a : bez medzier), UTF-8. Keď váš framework sprístupňuje iba analyzovaný JSON,
máte aj druhý, úplne ekvivalentný postup:
znova ho kanonicky serializujte a vypočítajte z neho HMAC.
# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
Uprednostnite nespracované telo — je to o jeden krok menej a je odolné voči zvláštnostiam opakovaného prevodu čísel JSON v niektorých jazykoch.
Ktoré tajomstvo?
| Zdroj | Tajomstvo |
|---|---|
Koncový bod webhooku (/v1/developer/webhook-endpoints) | Tajomstvo secret pre každý koncový bod (48 hexadecimálnych znakov), vrátené iba raz pri vytvorení |
| Starší webhook s jednou URL | Tajomstvo secret pre organizáciu, vrátené pri GET /v1/webhook |
Volanie koncového bodu nástroja (priame volanie vášho endpoint.url) | Tajomstvo webhooku na úrovni organizácie (rovnaké ako pre starší webhook s jednou URL) — nie tajomstvo pre jednotlivý koncový bod |
Uložte tajomstvo do správcu tajomstiev alebo premennej prostredia — nikdy ho neodovzdávajte do repozitára.
Referenčné implementácie
Všetky štyri overujú nespracované telo požiadavky:
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
Zapojenie špecifické pre 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})
Overovanie volaní nástrojov
Keď hlasový agent priamo zavolá jeden z vašich
funkčných nástrojov (nástroj má
endpoint), požiadavka obsahuje dve hlavičky ThunderPhone spolu
s vašimi nakonfigurovanými endpoint.headers:
X-ThunderPhone-Call-ID— číselné ID prebiehajúceho hovoru.X-ThunderPhone-Signature— HMAC-SHA256 s kľúčom vášho tajného kľúča webhooku na úrovni organizácie, nad presnými bajtmi tela požiadavky.
Rovnaký pomocník verify() funguje bez zmien, s dvoma rozdielmi:
- Nástroje
GET/DELETEnemajú telo. Argumenty sa prenášajú ako parametre dopytu a podpis sa vypočítava nad prázdnym bajtovým reťazcom — tedaverify(b"", sig, secret)(Python) aleboverify(Buffer.alloc(0), sig, secret)(Node). Nehašujte reťazec dopytu. - Organizácie bez nakonfigurovaného staršieho webhooku nemajú tajný kľúč organizácie. V
takom prípade volania nástrojov obsahujú len
X-ThunderPhone-Call-IDa žiadnu hlavičku podpisu. Nakonfigurujte starší webhook (PUT /v1/webhook) na získanie podpisového tajného kľúča alebo overujte volania nástrojov vlastnou hlavičkou prostredníctvomendpoint.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)
...
Odosielanie nástrojov v režime webhooku (nástroje bez endpoint, doručované
na webhook vašej organizácie ako telephony.tool / web.tool) je bežný
podpísaný webhook — platí preň štandardný postup uvedený vyššie. Oba
formáty požiadaviek nájdete v časti
Funkčné nástroje.
Bežné úskalia
Opätovná serializácia s predvoleným formátovaním
Parsovanie tela a jeho opätovné zapísanie s predvolenými
nastaveniami vašej knižnice JSON (medzery po , / :, kľúče v poradí vloženia) vytvorí
odlišné bajty a naruší HMAC. Overte nespracované telo — alebo ak ho
musíte znova serializovať, presne dodržte náš kanonický formát: zoradené
kľúče, kompaktné oddeľovače, UTF-8.
Framework automaticky parsuje JSON
Middleware express.json() v Express spotrebuje stream tela
a stratíte nespracované bajty. Použite express.raw() priamo na trase
webhooku alebo uložte nespracované telo do vyrovnávacej pamäte v predbežnom middleware.
To isté platí pre NestJS / Koa — pozrite si ich dokumentáciu k „raw body“.
Porovnanie nebezpečné z hľadiska časovania
expected === signature v JS alebo expected == signature v
Pythone sú porovnania s premenlivým časovaním. Použite príslušne crypto.timingSafeEqual
alebo hmac.compare_digest. Rozdiel vo výkone
je nulový.
Nesprávne tajomstvo pre koncové body nástrojov
Priame volania koncových bodov nástrojov sú podpisované pomocou tajomstva webhooku
na úrovni organizácie (GET /v1/webhook) — nie pomocou tajomstva pre jednotlivý koncový bod
z /v1/developer/webhook-endpoints. Znova použite rovnakú funkciu verify(),
ale uistite sa, že jej na trasách nástrojov odovzdávate tajomstvo organizácie.
Hashovanie reťazca dopytu pri nástrojoch GET/DELETE
Pri metódach nástrojov bez tela podpis pokrýva prázdny bajtový reťazec, čím sa zachová jeden univerzálny postup: vytvorte HMAC z nespracovaného tela požiadavky, nech je akékoľvek. Hashovanie adresy URL alebo reťazca dopytu sa nikdy nezhoduje.
Nevrátenie 401 pri nezhode
Vrátenie 200 pri neúspešnom overení robí z handlera cieľ opakovaného prehrania požiadaviek. Ak overenie zlyhá, vždy odpovedzte stavom iným než 2xx.
Ďalšie kroky
Sémantika doručovania, opakované pokusy, zdrojové IP adresy.
Spravujte viacero adries URL, rotujte tajomstvá.
Dve cesty volania nástrojov a tvary ich požiadaviek.
Vytvorte kompletnú integráciu podporovanú nástrojmi od začiatku do konca.