ThunderPhone 2.0 jest już dostępny.Uruchom samodzielnie, od 2¢/min.Przeczytaj komunikat

Operations

Weryfikowanie podpisów webhooków

Każdy webhook i żądanie narzędzia wysyłane przez ThunderPhone jest podpisane znacznikiem czasu. Zweryfikuj je za pomocą przepisu lub pomocnika SDK dostępnego tutaj i odrzucaj ponownie odtwarzane żądania starsze niż pięć minut.

Każde żądanie, które wysyłamy na Twój serwer (dostawy webhooków, starszy webhook z jednym adresem URL oraz wywołania punktów końcowych narzędzi), zawiera dwa nagłówki podpisu. Oba używają HMAC-SHA256 z tym samym sekretem.

NagłówekPodpisujeOchrona przed ponownym odtworzeniem
X-ThunderPhone-Signature-V2 (zalecany)Sygnatura czasowa, metoda, ścieżka, zapytanie i treśćOdrzuć żądania starsze niż 5 minut
X-ThunderPhone-SignatureTylko treśćBrak: przechwycone żądanie można weryfikować bezterminowo

W nowych integracjach weryfikuj X-ThunderPhone-Signature-V2 i przenieś do niego istniejące integracje. X-ThunderPhone-Signature jest nadal wysyłany z każdym żądaniem, bez zmian, więc weryfikatory oparte na nim nadal działają. Ogłosimy datę, zanim przestaniemy go wysyłać.

Podpis V2

X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e
  • t to czas Unix w sekundach, w którym wysłaliśmy żądanie. Ponowiona dostawa jest podpisywana ponownie podczas wysyłania, z własnym t.
  • v1 to szesnastkowy HMAC-SHA256 podpisywanego ciągu poniżej, utworzony za pomocą Twojego sekretu.

Wartość jest listą par key=value rozdzielonych przecinkami. Ignoruj klucze, których nie rozpoznajesz, i zaakceptuj żądanie, jeśli pasuje dowolne v1: możemy dodać drugi podpis podczas przyszłej zmiany.

Podpisywany ciąg

Cztery pola, po każdym jeden znak nowej linii (\n, bajt 0x0A) z wyjątkiem ostatniego:

<t>\n<METHOD>\n<path and query>\n<body>
PoleWartość
tWartość t z nagłówka
METHODMetoda HTTP zapisana wielkimi literami: POST, GET, PUT, PATCH lub DELETE
path and queryCel żądania z wiersza żądania HTTP: ścieżka, następnie ? i ciąg zapytania, jeśli występuje. Bez schematu, hosta ani fragmentu. Użyj go dokładnie w otrzymanej postaci, bez dekodowania ani zmiany kolejności parametrów
bodySurowe ciało żądania. Wywołania narzędzi GET i DELETE nie mają ciała, więc ich podpisywany ciąg kończy się znakiem nowej linii po ścieżce

Aby zweryfikować:

  1. Przed jakimkolwiek parsowaniem odczytaj nagłówek, metodę, surową ścieżkę i zapytanie oraz surowe ciało.
  2. Pobierz t oraz każde v1 z nagłówka.
  3. Odrzuć żądanie, jeśli t wyprzedza lub opóźnia się względem Twojego zegara o więcej niż 300 sekund.
  4. Oblicz szesnastkowy HMAC-SHA256 podpisywanego ciągu za pomocą swojego sekretu.
  5. Porównaj go z v1 w czasie stałym. Odpowiedz 401, jeśli nic nie pasuje.

Przykład

Sekret whsec_example_4c1f2a9b. Wysyłamy to żądanie o 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"}

Podpisywany ciąg to te cztery wiersze, bez znaku nowej linii po ciele:

1767225600
POST
/tools/lookup?region=us
{"customer_id":"cust_123"}

Jego HMAC-SHA256 to 03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e. Sprawdź względem niego swoją implementację albo odtwórz go za pomocą openssl:

printf '1767225600\nPOST\n/tools/lookup?region=us\n{"customer_id":"cust_123"}' \
  | openssl dgst -sha256 -hmac 'whsec_example_4c1f2a9b'

Wywołanie narzędzia GET do /tools/lookup?customer_id=cust_123 w tej samej sekundzie ma puste ciało. Jego podpisywany ciąg to 1767225600\nGET\n/tools/lookup?customer_id=cust_123\n, a jego podpis to 3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280.

Z użyciem SDK

SDK dla Pythona i TypeScript zawierają funkcję pomocniczą (thunderphone 0.2.0 lub nowszy, @thunderphone/sdk 0.2.0 lub nowszy). Sprawdza ona 5-minutowe okno, porównuje w czasie stałym i akceptuje listę sekretów, dzięki czemu możesz je rotować bez przestoju.

Python
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
)
Node.js
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
});

Bez pakietu SDK

Python
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)
Node.js
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)
    );
  });
}

Integracja z frameworkiem

Każdy przykład odczytuje surową ścieżkę i zapytanie oraz surowe ciało żądania, a następnie wywołuje funkcję pomocniczą SDK.

FastAPI
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}
Express
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);
  },
);
Django
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) oraz ASGI raw_path i query_string (FastAPI) stanowią docelowy adres dokładnie w otrzymanej postaci. Metody Django get_full_path() i Flask request.path najpierw dekodują ścieżkę, co dla zwykłych ścieżek daje ten sam ciąg znaków. Jeśli ścieżka endpointu zawiera znaki zakodowane procentowo lub zastrzeżone, takie jak %2F, ; lub =, odczytaj zamiast tego surowy adres docelowy udostępniany przez serwer (na przykład request.META["RAW_URI"] w Gunicorn).

Który sekret?

Ten sam sekret podpisuje oba nagłówki.

ŹródłoSekret
Endpoint webhooka (/v1/developer/webhook-endpoints)Sekret secret dla danego endpointu (48 znaków szesnastkowych), zwracany jednorazowo podczas tworzenia
Starszy webhook z pojedynczym adresem URLSekret secret dla organizacji zwracany przez GET /v1/webhook
Wywołanie endpointu narzędzia (bezpośrednie wywołanie Twojego endpoint.url)Sekret webhooka na poziomie organizacji (ten sam co dla starszego webhooka z pojedynczym adresem URL) — nie sekret dla pojedynczego endpointu

Przechowuj sekret w menedżerze sekretów lub zmiennej środowiskowej — nigdy nie zapisuj go w repozytorium.

Weryfikowanie wywołań narzędzi

Gdy agent bezpośrednio wywołuje jedno z Twoich narzędzi funkcyjnych (narzędzie ma endpoint), żądanie zawiera następujące nagłówki ThunderPhone wraz ze skonfigurowanym przez Ciebie endpoint.headers:

  • X-ThunderPhone-Call-ID — numeryczny identyfikator aktywnego połączenia.
  • X-ThunderPhone-Signature-V2 — oparty na znaczniku czasu podpis powyżej, powiązany z Twoim sekretem webhooka na poziomie organizacji.
  • X-ThunderPhone-Signature — starszy podpis obejmujący tylko treść żądania, z tym samym kluczem.

Dwie rzeczy różnią się od webhooków:

  1. Narzędzia GET / DELETE nie mają treści żądania. Argumenty są przekazywane jako parametry zapytania. Podpis V2 obejmuje je za pośrednictwem ścieżki i zapytania, a pole treści jest puste. Weryfikuj przy użyciu nieprzetworzonego ciągu zapytania: analizowanie parametrów i tworzenie nowego ciągu zapytania może zmienić ich kolejność lub kodowanie. Starszy nagłówek podpisuje pusty ciąg bajtów.
  2. Organizacje bez skonfigurowanego starszego webhooka nie mają sekretu organizacji. W takim przypadku wywołania narzędzi zawierają tylko X-ThunderPhone-Call-ID i nie zawierają nagłówków podpisu. Skonfiguruj starszy webhook (PUT /v1/webhook), aby uzyskać sekret do podpisywania, lub uwierzytelniaj wywołania narzędzi własnym nagłówkiem za pośrednictwem 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)
    ...

W Express parser treści żądania pozostawia req.body jako pusty obiekt przy żądaniu GET, dlatego przekaż Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0).

Dystrybucja wywołań narzędzi w trybie webhooka (narzędzia bez endpoint, dostarczane do webhooka organizacji jako telephony.tool / web.tool) to zwykły podpisany webhook — obowiązuje standardowa procedura opisana powyżej. Zobacz Narzędzia funkcyjne, aby poznać oba formaty żądań.

Starszy podpis: X-ThunderPhone-Signature

Oryginalny nagłówek to szesnastkowy HMAC-SHA256 samego surowego treści żądania, z tym samym sekretem. Nie zawiera znacznika czasu, więc każdy, kto przechwyci jedno podpisane żądanie, może je ponownie odtworzyć. Jest nadal wysyłany przy każdym żądaniu, gdy integracje przechodzą na V2.

  1. Odczytaj surową treść żądania — dokładne bajty, które Ci wysłaliśmy.
  2. Oblicz hmac_sha256(secret, body).hexdigest().
  3. Porównaj w czasie stałym z X-ThunderPhone-Signature.

Podpisujemy dokładnie te bajty, które przesyłamy. Te bajty są również kanoniczną serializacją JSON ładunku — klucze posortowane alfabetycznie, zwarte separatory (, i : bez spacji), UTF-8. Jeśli Twój framework udostępnia tylko sparsowany JSON, ponownie go zserializuj kanonicznie i oblicz dla niego HMAC:

# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")

Preferuj surową treść — to o jeden krok mniej i zapewnia odporność na problemy z ponowną konwersją liczb JSON w niektórych językach. To samo dotyczy pola treści w V2.

Python
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 "")
Node.js
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),
  );
}
Go
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))
}
Ruby
require "openssl"
 
def verify(body, signature, secret)
  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, body)
  Rack::Utils.secure_compare(expected, signature.to_s)
end

Typowe pułapki

Rozbieżność zegara

5-minutowe okno jest mierzone względem zegara Twojego serwera. Utrzymuj go zsynchronizowanego z NTP; zegar opóźniony lub przyspieszony o ponad 5 minut odrzuca każde żądanie.

Serwer proxy zmienia ścieżkę

Podpisana ścieżka to ta, o którą poprosiliśmy: ścieżka adresu URL skonfigurowanego w ThunderPhone. Jeśli odwrotny serwer proxy lub brama API usuwa albo dodaje prefiks, zanim aplikacja zobaczy żądanie, odtwórz ścieżkę, którą wysłaliśmy (na przykład przywróć prefiks), przed weryfikacją.

Ponowna serializacja z domyślnym formatowaniem

Przetworzenie treści i ponowne zapisanie jej przy użyciu domyślnych ustawień biblioteki JSON (spacje po , / :, klucze w kolejności wstawiania) generuje inne bajty i unieważnia HMAC. Zweryfikuj surową treść — lub jeśli musisz ponownie ją serializować, dokładnie dopasuj naszą formę kanoniczną: posortowane klucze, zwarte separatory, UTF-8.

Framework automatycznie przetwarza JSON

Oprogramowanie pośredniczące Express express.json() zużywa strumień treści i tracisz surowe bajty. Użyj express.raw() konkretnie dla trasy webhooka albo buforuj surową treść w oprogramowaniu pośredniczącym poprzedzającym parser. Tak samo jest w NestJS / Koa — sprawdź ich dokumentację dotyczącą surowego ciała żądania.

Porównanie niebezpieczne czasowo

expected === signature w JS lub expected == signature w Pythonie to porównania zależne od czasu wykonania. Użyj odpowiednio crypto.timingSafeEqual lub hmac.compare_digest. Różnica w wydajności jest zerowa.

Nieprawidłowy sekret dla punktów końcowych narzędzi

Bezpośrednie wywołania punktów końcowych narzędzi są podpisywane za pomocą sekretu webhooka na poziomie organizacji (GET /v1/webhook) — nie za pomocą sekretu konkretnego punktu końcowego z /v1/developer/webhook-endpoints. Użyj ponownie tego samego pomocnika, ale upewnij się, że przekazujesz mu sekret organizacji na trasach narzędzi.

Ciągi zapytania w narzędziach GET/DELETE

V2 podpisuje ciąg zapytania dokładnie w wysłanej postaci. Starszy nagłówek podpisuje pustą treść, a nigdy ciąg zapytania, więc obliczanie skrótu URL dla starszego nagłówka nigdy nie da zgodnego wyniku.

Duplikaty w oknie czasowym

Znacznik czasu blokuje powtórzenia starsze niż 5 minut, a nie drugą kopię w tym oknie. Dostarczenia do punktów końcowych odbywają się co najmniej raz, więc może również nadejść ponowna próba tego samego zdarzenia; usuwaj duplikaty na podstawie event_id koperty.

Brak zwracania 401 przy niezgodności

Zwracanie 200 po nieudanej weryfikacji sprawia, że procedura obsługi staje się celem ataków typu replay. Zawsze odpowiadaj kodem innym niż 2xx, jeśli weryfikacja się nie powiedzie.


Kolejne kroki