Open in
Veebikonksu allkirjade kontrollimine
Iga ThunderPhone
Iga päring, mille sinu serverile saadame (veebikonksu edastused, pärandatud ühe URL-iga veebikonks ja tööriista lõpp-punkti kutsed), sisaldab kahte allkirjapäist. Mõlemad kasutavad sama saladusega HMAC-SHA256-t.
| Päis | Allkirjastab | Taasesitusrünnete kaitse |
|---|---|---|
X-ThunderPhone-Signature-V2 (soovitatud) | Ajatempel, meetod, tee, päring ja sisu | Lükka tagasi üle 5 minuti vanused päringud |
X-ThunderPhone-Signature | Ainult sisu | Puudub: jäädvustatud päring kinnitatakse alati |
Kontrolli uutes integratsioonides X-ThunderPhone-Signature-V2 ja vii
olemasolevad integratsioonid sellele üle. X-ThunderPhone-Signature saadetakse
endiselt muutmata kujul iga päringuga, seega sellel põhinevad kontrollijad
jätkavad töötamist. Teatame kuupäeva enne selle saatmise lõpetamist.
Allkiri V2
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4eton Unix-aeg sekundites, mil päringu saatsime. Uuesti proovitud edastus allkirjastatakse saatmisel uuesti omatväärtusega.v1on alloleva allkirjastatud stringi kuueteistkümnendsüsteemis HMAC-SHA256, mis on arvutatud sinu saladusvõtmega.
Väärtus on komadega eraldatud key=value paaride loend. Ignoreeri võtmeid,
mida sa ei tunne, ja aktsepteeri päringut, kui mõni v1 ühtib: tulevikus
võime muudatuse käigus lisada teise allkirja.
Allkirjastatud string
Neli välja, millest igaühele järgneb üks reavahetus (\n, bait 0x0A),
välja arvatud viimasele:
<t>\n<METHOD>\n<path and query>\n<body>| Väli | Väärtus |
|---|---|
t | Päise t väärtus |
METHOD | HTTP-meetod suurtähtedega: POST, GET, PUT, PATCH või DELETE |
| tee ja päring | Päringu sihtkoht HTTP päringurealt: tee, seejärel ? ja päringustring, kui see on olemas. Ilma skeemi, hosti või fragmendita. Kasuta seda täpselt saabunud kujul, ilma dekodeerimata või parameetreid ümber järjestamata |
body | Toores päringukeha. GET- ja DELETE-tööriistakutsetel pole keha, seega lõpeb nende allkirjastatud string teele järgneva reavahetusega |
Kontrollimiseks:
- Enne mis tahes parsimist loe päis, meetod, töötlemata tee ja päring ning töötlemata keha.
- Võta päisest
tja kõikv1väärtused. - Lükka päring tagasi, kui
ton sinu kellast rohkem kui 300 sekundit ees või taga. - Arvuta allkirjastatud stringi kuueteistkümnendsüsteemis HMAC-SHA256 sinu saladusvõtmega.
- Võrdle seda
v1väärtusega konstantse ajaga. Kui miski ei ühti, vasta401.
Töötav näide
Saladus whsec_example_4c1f2a9b. Saadame selle päringu ajal
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"}Allkirjastatud string koosneb nendest neljast reast, ilma kehale järgneva reavahetuseta:
1767225600
POST
/tools/lookup?region=us
{"customer_id":"cust_123"}Selle HMAC-SHA256 on
03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e.
Kontrolli selle vastu oma teostust või loo see uuesti openssl abil:
printf '1767225600\nPOST\n/tools/lookup?region=us\n{"customer_id":"cust_123"}' \
| openssl dgst -sha256 -hmac 'whsec_example_4c1f2a9b'Sama sekundi jooksul tehtud GET-tööriistakutsel aadressile
/tools/lookup?customer_id=cust_123 on tühi keha. Selle allkirjastatud string on
1767225600\nGET\n/tools/lookup?customer_id=cust_123\n ja selle allkiri
on 3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280.
SDK-dega
Python- ja TypeScripti SDK-d sisaldavad abifunktsiooni
(thunderphone 0.2.0 või uuem, @thunderphone/sdk 0.2.0 või uuem). See
kontrollib 5-minutilist ajavahemikku, võrdleb konstantse ajaga ja aktsepteerib
saladuste loendit, et saaksid neid vahetada ilma seisakuta.
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
});Ilma SDK-ta
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)
);
});
}Raamistiku seadistamine
Iga näide loeb toore tee ja päringu ning toore keha, seejärel kutsub välja SDK abifunktsiooni.
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) ning ASGI raw_path ja query_string
(FastAPI) on siht täpselt vastuvõetud kujul. Django
get_full_path() ja Flaski request.path dekodeerivad tee esmalt, mis
annab tavaliste teede puhul sama stringi. Kui sinu lõpp-punkti tee sisaldab
protsendikodeeritud või reserveeritud märke, näiteks %2F, ; või =, loe selle asemel
serveri pakutavat toorsihti (näiteks request.META["RAW_URI"]
Gunicornis).
Milline saladus?
Mõlemale päisele lisatakse allkiri sama saladusega.
| Allikas | Saladus |
|---|---|
Webhooki lõpp-punkt (/v1/developer/webhook-endpoints) | Lõpp-punkti secret (48 kuueteistkümnendsümbolit), mis tagastatakse loomisel üks kord |
| Pärandatud ühe URL-iga webhook | Organisatsiooni secret, mis tagastatakse käsuga GET /v1/webhook |
Tööriista lõpp-punkti kutse (otsene kutse sinu endpoint.url-ile) | Organisatsioonitaseme webhooki saladus (sama mis pärandatud ühe URL-iga webhookil) — mitte lõpp-punktipõhine saladus |
Hoia saladust oma saladuste halduris või keskkonnamuutujas — ära kunagi lisa seda versioonihaldusse.
Tööriistakutsete kontrollimine
Kui häälagent kutsub ühe sinu
funktsioonitööriistadest otse välja (tööriistal on
endpoint), sisaldab päring lisaks seadistatud endpoint.headers-ile järgmisi ThunderPhone'i päiseid:
X-ThunderPhone-Call-ID— aktiivse kõne numbriline ID.X-ThunderPhone-Signature-V2— ülaltoodud ajatempliga signatuur, mis on loodud sinu organisatsioonitaseme veebikonksu saladusega.X-ThunderPhone-Signature— pärandversiooni ainult kehasisu hõlmav signatuur, sama võtmega.
Veebikonksudest erinevad kaks asja:
GET- jaDELETE-tööriistadel puudub kehasisu. Argumendid edastatakse päringuparameetritena. V2 signatuur hõlmab neid tee ja päringu kaudu ning kehasisu väli on tühi. Kontrolli töötlemata päringustringiga: parameetrite parsimine ja uue päringustringi koostamine võib muuta nende järjekorda või kodeeringut. Pärandpäis allkirjastab tühja baitstringi.- Organisatsioonidel, millel pole pärandveebikonksu seadistatud, puudub organisatsiooni saladus. Sel juhul sisaldavad tööriistakutsed ainult päist
X-ThunderPhone-Call-IDega sisalda signatuuripäiseid. Allkirjastamise saladuse saamiseks seadista pärandveebikonks (PUT /v1/webhook) või autendi tööriistakutsed oma päisegaendpoint.headerskaudu.
@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)
...Expressis jätab kehasisu parser GET-päringu korral req.body tühjaks objektiks, seega anna edasi Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0).
Veebikonksu-režiimis tööriistade edastamine (tööriistad ilma endpoint-ita,
mis saadetakse sinu organisatsiooni veebikonksu kui telephony.tool / web.tool)
on tavaline allkirjastatud veebikonks — rakendub ülaltoodud standardne juhis. Mõlema
päringukuju kohta vaata funktsioonitööriistu.
Pärandsignatuur: X-ThunderPhone-Signature
Algne päis on ainult töötlemata keha heksakujuline HMAC-SHA256, kasutades sama saladust. See ei sisalda ajatemplit, seega saab igaüks, kes ühe allkirjastatud päringu kinni püüab, selle uuesti esitada. Seda saadetakse endiselt iga päringuga, kuni integratsioonid liiguvad üle V2-le.
- Loe päringu töötlemata keha — täpselt need baidid, mille sulle saatsime.
- Arvuta
hmac_sha256(secret, body).hexdigest(). - Võrdle konstantsel ajal väärtusega
X-ThunderPhone-Signature.
Allkirjastame täpselt need baidid, mida edastame. Need baidid on ka
koormuse kanooniline JSON-serialiseering — võtmed tähestikulises
järjekorras, kompaktsed eraldajad (, ja : ilma tühikuteta), UTF-8.
Kui sinu raamistik pakub ainult sõelutud JSON-i, serialiseeri see kanooniliselt
uuesti ja arvuta selle HMAC:
# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")Eelista töötlemata keha — see on ühe sammu võrra lihtsam ja väldib mõnes keeles JSON-arvude edasi-tagasi teisendamise iseärasusi. Sama kehtib V2 kehavälja kohta.
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)
endLevinud vead
Kellanihe
5-minutilist ajavahemikku mõõdetakse sinu serveri kella järgi. Hoia see NTP-ga sünkroonis; rohkem kui 5 minutit vale kell lükkab tagasi iga päringu.
Puhverserver muudab teed
Allkirjastatud tee on see, mida taotlesime: ThunderPhone’is seadistatud URL-i tee. Kui pöördpuhverserver või API-lüüs eemaldab või lisab eesliite enne, kui sinu rakendus päringut näeb, taasta enne kontrollimist meie saadetud tee (näiteks lisa eesliide tagasi).
Vaikevorminguga uuesti serialiseerimine
Keha parsimine ja selle uuesti serialiseerimine sinu JSON-teegi
vaikeseadetega (tühikud pärast , / :, sisestusjärjestuses võtmed) annab
erinevad baidid ja rikub HMAC-i. Kontrolli töötlemata keha — või kui
pead selle uuesti serialiseerima, järgi täpselt meie kanoonilist vormi:
sorditud võtmed, kompaktsed eraldajad, UTF-8.
Raamistik parsib JSON-i automaatselt
Expressi express.json() vahetarkvara tarbib kehavoo
ja töötlemata baidid lähevad kaotsi. Kasuta veebikonksu marsruudil eraldi
express.raw()-i või puhverdage töötlemata keha eelvahetarkvaras.
Sama kehtib NestJS-i / Koa kohta — vaata nende „raw body” dokumentatsiooni.
Ajastusrünnakutele ebaturvaline võrdlus
expected === signature JS-is või expected == signature Pythonis on
ajastusest sõltuvad võrdlused. Kasuta vastavalt crypto.timingSafeEqual
või hmac.compare_digest. Jõudluse erinevus on olematu.
Tööriista lõpp-punktide jaoks vale saladus
Tööriista lõpp-punktide otsekõned allkirjastatakse organisatsioonitaseme webhooki
saladusega (GET /v1/webhook) — mitte ühegi lõpp-punktipõhise saladusega
asukohast /v1/developer/webhook-endpoints. Kasuta sama abifunktsiooni,
kuid veendu, et annad sellele tööriista marsruutidel organisatsiooni saladuse.
GET/DELETE-tööriistade päringustringid
V2 allkirjastab päringustringi täpselt saadetud kujul. Pärandpäis allkirjastab tühja keha, kuid mitte kunagi päringustringi, seega ei vasta URL-i räsimine pärandpäise puhul kunagi.
Duplikaadid ajavahemiku sees
Ajatempel peatab üle 5 minuti vanused kordused, mitte samas ajavahemikus
saabuva teise koopia. Lõpp-punkti edastused toimuvad vähemalt üks kord, seega
võib saabuda ka sama sündmuse korduskatse; eemalda duplikaadid ümbrise
event_id alusel.
Mittevastavuse korral 401 tagastamata jätmine
Ebaõnnestunud kinnitamise korral 200 tagastamine muudab töötleja kordusrünnaku sihtmärgiks. Kui kinnitamine ebaõnnestub, vasta alati mitte-2xx koodiga.