ThunderPhone 2.0 ir klāt.Sāciet patstāvīgi — no 2 ¢/min.Lasīt paziņojumu

Operations

Pārbaudiet tīmekļa aizķeru parakstus

Katrs ThunderPhone nosūtītais tīmekļa aizķeres un rīka pieprasījums tiek parakstīts ar laikspiedolu. Pārbaudiet to, izmantojot šeit sniegto recepti vai SDK palīgfunkciju, un noraidiet atkārtoti nosūtītus pieprasījumus, kas ir vecāki par piecām minūtēm.

Katram pieprasījumam, ko nosūtām uz jūsu serveri (tīmekļa aizķeru piegādēm, mantotā viena URL tīmekļa aizķera un rīku galapunktu izsaukumiem), ir divas paraksta galvenes. Abas izmanto HMAC-SHA256 ar vienu un to pašu noslēpumu.

GalveneParakstaAizsardzība pret atkārtojumu
X-ThunderPhone-Signature-V2 (ieteicams)Laikspiedolu, metodi, ceļu, vaicājumu un pamattekstuNoraidiet pieprasījumus, kas ir vecāki par 5 minūtēm
X-ThunderPhone-SignatureTikai pamattekstuNav: pārtverts pieprasījums tiek verificēts bez termiņa

Jaunās integrācijās verificējiet X-ThunderPhone-Signature-V2 un pārvietojiet uz to esošās integrācijas. X-ThunderPhone-Signature joprojām tiek nosūtīts ar katru pieprasījumu bez izmaiņām, tāpēc uz tā balstītie verificētāji turpina darboties. Pirms pārtrauksim to sūtīt, paziņosim datumu.

Paraksts V2

X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e
  • t ir Unix laiks sekundēs, kad nosūtījām pieprasījumu. Atkārtoti nosūtīta piegāde tiek parakstīta vēlreiz tās nosūtīšanas brīdī ar savu t.
  • v1 ir tālāk norādītās parakstītās virknes heksadecimālais HMAC-SHA256, izmantojot jūsu slepeno atslēgu.

Vērtība ir ar komatiem atdalīts key=value pāru saraksts. Ignorējiet atslēgas, kuras neatpazīstat, un pieņemiet pieprasījumu, ja atbilst jebkurš v1: turpmāku izmaiņu laikā varam pievienot otru parakstu.

Parakstītā virkne

Četri lauki, katram seko viena jauna rinda (\n, baits 0x0A), izņemot pēdējo:

<t>\n<METHOD>\n<path and query>\n<body>
LauksVērtība
tt vērtība no galvenes
METHODHTTP metode ar lielajiem burtiem: POST, GET, PUT, PATCH vai DELETE
path and queryPieprasījuma mērķis no HTTP pieprasījuma rindas: ceļš, pēc tam ? un vaicājuma virkne, ja tāda ir. Bez shēmas, resursdatora vai fragmenta. Izmantojiet to tieši tā, kā tas tika saņemts, to neatkodējot un nemainot parametru secību
bodyNeapstrādāts pieprasījuma pamatteksts. GET un DELETE rīka izsaukumiem nav pamatteksta, tādēļ to parakstītā virkne beidzas ar jauno rindu pēc ceļa

Lai verificētu:

  1. Pirms jebkādas parsēšanas nolasiet galveni, metodi, neapstrādāto ceļu un vaicājumu, kā arī neapstrādāto pamattekstu.
  2. No galvenes iegūstiet t un katru v1.
  3. Noraidiet pieprasījumu, ja t ir vairāk nekā 300 sekundes pirms vai pēc jūsu pulksteņa laika.
  4. Aprēķiniet parakstītās virknes heksadecimālo HMAC-SHA256, izmantojot savu slepeno atslēgu.
  5. Salīdziniet to ar v1 konstantā laikā. Atbildiet ar 401, ja nekas neatbilst.

Piemērs

Slepenā atslēga whsec_example_4c1f2a9b. Mēs nosūtām šo pieprasījumu laikā 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"}

Parakstītā virkne ir šīs četras rindas bez jaunas rindas pēc pamatteksta:

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

Tās HMAC-SHA256 ir 03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e. Pārbaudiet savu implementāciju ar to vai atveidojiet to, izmantojot openssl:

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

GET rīka izsaukumam uz /tools/lookup?customer_id=cust_123 tajā pašā sekundē ir tukšs pamatteksts. Tā parakstītā virkne ir 1767225600\nGET\n/tools/lookup?customer_id=cust_123\n, un tās paraksts ir 3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280.

Ar SDK

Python un TypeScript SDK ietver palīgfunkciju (thunderphone 0.2.0 vai jaunāka versija, @thunderphone/sdk 0.2.0 vai jaunāka versija). Tā pārbauda 5 minūšu logu, salīdzina konstantā laikā un pieņem slepeno atslēgu sarakstu, lai jūs varētu tās nomainīt bez dīkstāves.

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 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)
    );
  });
}

Sistēmas pieslēgšana

Katrs piemērs nolasa neapstrādāto ceļu un vaicājumu, kā arī neapstrādāto pamattekstu, pēc tam izsauc SDK palīgfunkciju.

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) un ASGI raw_path un query_string (FastAPI) ir mērķis tieši tāds, kā saņemts. Django get_full_path() un Flask request.path vispirms atkodē ceļu, kas parastiem ceļiem dod to pašu virkni. Ja jūsu galapunkta ceļā ir procentu kodētas vai rezervētas rakstzīmes, piemēram, %2F, ; vai =, tā vietā nolasiet servera nodrošināto neapstrādāto mērķi (piemēram, request.META["RAW_URI"] sistēmā Gunicorn).

Kurš noslēpums?

Tas pats noslēpums paraksta abas galvenes.

AvotsNoslēpums
Tīmekļa aizķeres galapunkts (/v1/developer/webhook-endpoints)Katram galapunktam paredzēts secret (48 heksadecimālās rakstzīmes), kas tiek atgriezts vienreiz izveides laikā
Mantotā tīmekļa aizķere ar vienu URLOrganizācijai paredzēts secret, kas tiek atgriezts ar GET /v1/webhook
Rīka galapunkta izsaukums (tiešs izsaukums uz jūsu endpoint.url)Organizācijas līmeņa tīmekļa aizķeres noslēpums (tas pats, kas mantotajai tīmekļa aizķerei ar vienu URL) — nevis katram galapunktam paredzēts noslēpums

Glabājiet noslēpumu savu noslēpumu pārvaldniekā vai vides mainīgajā — nekad to neiekļaujiet repozitorijā.

Rīku izsaukumu verificēšana

Kad balss aģents tieši izsauc kādu no jūsu funkciju rīkiem (rīkam ir endpoint), pieprasījumā kopā ar jūsu konfigurētajiem endpoint.headers tiek iekļautas šādas ThunderPhone galvenes:

  • X-ThunderPhone-Call-ID — aktīvā zvana skaitliskais ID.
  • X-ThunderPhone-Signature-V2 — iepriekš norādītais paraksts ar laika zīmogu, kas veidots, izmantojot jūsu organizācijas līmeņa webhook noslēpumu.
  • X-ThunderPhone-Signature — mantotais paraksts, kas attiecas tikai uz pamattekstu, ar to pašu atslēgu.

No webhook atšķiras divas lietas:

  1. GET / DELETE rīkiem nav pamatteksta. Argumenti tiek nodoti kā vaicājuma parametri. V2 paraksts tos aptver, izmantojot ceļu un vaicājumu, bet pamatteksta lauks ir tukšs. Verificējiet, izmantojot neapstrādāto vaicājuma virkni: parametru parsēšana un jaunas vaicājuma virknes izveide var mainīt to secību vai kodējumu. Mantotā galvene paraksta tukšu baitu virkni.
  2. Organizācijām bez konfigurēta mantotā webhook nav organizācijas noslēpuma. Šādā gadījumā rīku izsaukumos ir tikai X-ThunderPhone-Call-ID un nav paraksta galveņu. Konfigurējiet mantoto webhook (PUT /v1/webhook), lai iegūtu parakstīšanas noslēpumu, vai autentificējiet rīku izsaukumus ar savu galveni, izmantojot 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)
    ...

Programmā Express pamatteksta parsētājs GET pieprasījumā atstāj req.body kā tukšu objektu, tāpēc nododiet Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0).

Tīmekļa aizķeres režīma rīku nosūtīšana (rīki bez endpoint, kas tiek piegādāti uz jūsu organizācijas tīmekļa aizķeri kā telephony.tool / web.tool) ir parasta parakstīta tīmekļa aizķere — piemērojama iepriekš norādītā standarta procedūra. Abus pieprasījumu formātus skatiet sadaļā Funkciju rīki.

Mantotais paraksts: X-ThunderPhone-Signature

Sākotnējā galvene ir tikai neapstrādātā pamatteksta heksadecimālais HMAC-SHA256, izmantojot to pašu noslēpumu. Tā nesatur laikspiedolu, tāpēc ikviens, kurš pārtver vienu parakstītu pieprasījumu, var to atkārtoti nosūtīt. Tā joprojām tiek sūtīta ar katru pieprasījumu, kamēr integrācijas pāriet uz V2.

  1. Nolasiet neapstrādāto pieprasījuma pamattekstu — precīzos baitus, ko jums nosūtījām.
  2. Aprēķiniet hmac_sha256(secret, body).hexdigest().
  3. Salīdziniet konstantā laikā ar X-ThunderPhone-Signature.

Mēs parakstām tieši tos baitus, kurus pārsūtām. Šie baiti ir arī slodzes kanoniskā JSON serializācija — atslēgas sakārtotas alfabētiski, kompakti atdalītāji (, un : bez atstarpēm), UTF-8. Ja jūsu ietvars nodrošina tikai parsētu JSON, serializējiet to atkārtoti kanoniski un aprēķiniet tam HMAC:

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

Dodiet priekšroku neapstrādātajam pamattekstam — tas ir par vienu soli mazāk un nav pakļauts JSON skaitļu atkārtotas pārveidošanas īpatnībām dažās valodās. Tas pats attiecas uz V2 pamatteksta lauku.

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

Biežākās kļūdas

Pulksteņa laika nobīde

5 minūšu periods tiek mērīts pēc jūsu servera pulksteņa. Sinhronizējiet to ar NTP; pulkstenis, kas atpaliek vai steidzas vairāk nekā par 5 minūtēm, noraida ikvienu pieprasījumu.

Starpniekserveris pārraksta ceļu

Parakstītais ceļš ir tas, kuru pieprasījām: URL ceļš, ko konfigurējāt ThunderPhone. Ja reversais starpniekserveris vai API vārteja noņem vai pievieno prefiksu, pirms jūsu lietojumprogramma saņem pieprasījumu, pirms pārbaudes atjaunojiet mūsu nosūtīto ceļu (piemēram, pievienojiet prefiksu atpakaļ).

Atkārtota serializēšana ar noklusējuma formatējumu

Pamatteksta parsēšana un atkārtota serializēšana ar jūsu JSON bibliotēkas noklusējuma iestatījumiem (atstarpes pēc , / :, ievietošanas secībā sakārtotas atslēgas) rada atšķirīgus baitus un sabojā HMAC. Pārbaudiet neapstrādāto pamattekstu — vai, ja tas ir atkārtoti jāserializē, precīzi ievērojiet mūsu kanonisko formu: sakārtotas atslēgas, kompakti atdalītāji, UTF-8.

Ietvars automātiski parsē JSON

Express express.json() starpprogrammatūra patērē pamatteksta plūsmu, un jūs zaudējat neapstrādātos baitus. Konkrēti tīmekļa aizķeres maršrutā izmantojiet express.raw() vai saglabājiet neapstrādāto pamattekstu buferī pirms starpprogrammatūras. Tas pats attiecas uz NestJS / Koa — skatiet to dokumentāciju par “neapstrādātu pamattekstu”.

Salīdzināšana, kas nav droša pret laika uzbrukumiem

expected === signature JS vai expected == signature valodā Python ir salīdzinājumi ar mainīgu izpildes laiku. Attiecīgi izmantojiet crypto.timingSafeEqual vai hmac.compare_digest. Veiktspējas atšķirība ir niecīga.

Nepareizs noslēpums rīku galapunktiem

Tiešie rīku galapunktu izsaukumi tiek parakstīti ar organizācijas līmeņa tīmekļa aizķeres noslēpumu (GET /v1/webhook) — nevis ar katra galapunkta noslēpumu no /v1/developer/webhook-endpoints. Izmantojiet to pašu palīgfunkciju, bet pārliecinieties, ka rīku maršrutos tai nododat organizācijas noslēpumu.

Vaicājuma virknes GET/DELETE rīkos

V2 paraksta vaicājuma virkni tieši tādu, kāda tā ir nosūtīta. Mantotā galvene paraksta tukšo pamattekstu, bet nekad vaicājuma virkni, tāpēc URL jaucējvērtības aprēķināšana mantotajai galvenei nekad nesakritīs.

Dublikāti periodā

Laikspiedols aptur atkārtotus pieprasījumus, kas ir vecāki par 5 minūtēm, nevis otru kopiju šajā periodā. Galapunktu piegādes notiek vismaz vienu reizi, tāpēc var tikt saņemts arī tā paša notikuma atkārtots mēģinājums; novērsiet dublikātus pēc aploksnes event_id.

401 neatgriešana neatbilstības gadījumā

200 atgriešana neveiksmīgas verifikācijas gadījumā padara apstrādātāju par atkārtotas atskaņošanas uzbrukuma mērķi. Ja verifikācija neizdodas, vienmēr atbildiet ar statusu, kas nav 2xx.


Nākamās darbības