ThunderPhone 2.0 on nüüd saadaval.Iseteenindusena alates 2 senti/min.Loe uudist

Operations

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äisAllkirjastabTaasesitusrünnete kaitse
X-ThunderPhone-Signature-V2 (soovitatud)Ajatempel, meetod, tee, päring ja sisuLükka tagasi üle 5 minuti vanused päringud
X-ThunderPhone-SignatureAinult sisuPuudub: 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=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e
  • t on Unix-aeg sekundites, mil päringu saatsime. Uuesti proovitud edastus allkirjastatakse saatmisel uuesti oma t väärtusega.
  • v1 on 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äliVäärtus
tPäise t väärtus
METHODHTTP-meetod suurtähtedega: POST, GET, PUT, PATCH või DELETE
tee ja päringPä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
bodyToores päringukeha. GET- ja DELETE-tööriistakutsetel pole keha, seega lõpeb nende allkirjastatud string teele järgneva reavahetusega

Kontrollimiseks:

  1. Enne mis tahes parsimist loe päis, meetod, töötlemata tee ja päring ning töötlemata keha.
  2. Võta päisest t ja kõik v1 väärtused.
  3. Lükka päring tagasi, kui t on sinu kellast rohkem kui 300 sekundit ees või taga.
  4. Arvuta allkirjastatud stringi kuueteistkümnendsüsteemis HMAC-SHA256 sinu saladusvõtmega.
  5. Võrdle seda v1 väärtusega konstantse ajaga. Kui miski ei ühti, vasta 401.

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.

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

Ilma SDK-ta

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

Raamistiku seadistamine

Iga näide loeb toore tee ja päringu ning toore keha, seejärel kutsub välja SDK abifunktsiooni.

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) 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.

AllikasSaladus
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 webhookOrganisatsiooni 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:

  1. GET- ja DELETE-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.
  2. Organisatsioonidel, millel pole pärandveebikonksu seadistatud, puudub organisatsiooni saladus. Sel juhul sisaldavad tööriistakutsed ainult päist X-ThunderPhone-Call-ID ega sisalda signatuuripäiseid. Allkirjastamise saladuse saamiseks seadista pärandveebikonks (PUT /v1/webhook) või autendi tööriistakutsed oma päisega endpoint.headers kaudu.
@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.

  1. Loe päringu töötlemata keha — täpselt need baidid, mille sulle saatsime.
  2. Arvuta hmac_sha256(secret, body).hexdigest().
  3. 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.

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

Levinud 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.


Järgmised sammud