Επαλήθευση υπογραφών webhook

Κάθε αίτημα που στέλνουμε στον διακομιστή σας — παραδόσεις webhook και επικλήσεις τελικών σημείων εργαλείων — φέρει υπογραφή HMAC-SHA256 στην κεφαλίδα X-ThunderPhone-Signature. Ρυθμίστε σωστά την επαλήθευση μία φορά και χρησιμοποιήστε τον ίδιο βοηθό σε κάθε χειριστή.

Ο αλγόριθμος

  1. Διαβάστε το ακατέργαστο σώμα του αιτήματος — τα ακριβή byte που σας στείλαμε με POST.
  2. Υπολογίστε hmac_sha256(secret, body).hexdigest().
  3. Συγκρίνετε σε σταθερό χρόνο με το X-ThunderPhone-Signature. (Η απλή σύγκριση συμβολοσειρών διαρρέει πληροφορίες χρονισμού.)

Υπογράφουμε ακριβώς τα byte που μεταδίδουμε, επομένως η επαλήθευση του ακατέργαστου σώματος λειτουργεί πάντα. Αυτά τα byte είναι επίσης η κανονική σειριοποίηση JSON του ωφέλιμου φορτίου — κλειδιά ταξινομημένα αλφαβητικά, συμπαγείς διαχωριστές (, και : χωρίς κενά), UTF-8. Αυτό σας παρέχει μια δεύτερη, πλήρως ισοδύναμη μέθοδο όταν το framework σας εκθέτει μόνο αναλυμένο JSON: επανασειριοποιήστε κανονικά και υπολογίστε το HMAC αυτού.

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

Προτιμήστε το ακατέργαστο σώμα — είναι ένα βήμα λιγότερο και δεν επηρεάζεται από ιδιαιτερότητες επαναμετατροπής αριθμών JSON σε ορισμένες γλώσσες.

Ποιο μυστικό;

ΠηγήΜυστικό
Τελικό σημείο webhook (/v1/developer/webhook-endpoints)secret ανά τελικό σημείο (48 δεκαεξαδικοί χαρακτήρες) που επιστρέφεται μία φορά κατά τη δημιουργία
Παλιό webhook μίας διεύθυνσης URLsecret ανά οργανισμό που επιστρέφεται από το GET /v1/webhook
Επίκληση τελικού σημείου εργαλείου (άμεση κλήση στο endpoint.url σας)Το μυστικό webhook σε επίπεδο οργανισμού (το ίδιο με εκείνο του παλιού webhook μίας διεύθυνσης URL) — όχι μυστικό ανά τελικό σημείο

Αποθηκεύστε το μυστικό στον διαχειριστή μυστικών σας ή σε μεταβλητή περιβάλλοντος — μην το υποβάλετε ποτέ σε commit.

Υλοποιήσεις αναφοράς

Και οι τέσσερις επαληθεύουν το ακατέργαστο σώμα του αιτήματος:

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

Σύνδεση ανά 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})

Επαλήθευση κλήσεων εργαλείων

Όταν ο πράκτορας καλεί απευθείας ένα από τα εργαλεία συναρτήσεων σας (το εργαλείο διαθέτει ένα endpoint), το αίτημα περιλαμβάνει δύο κεφαλίδες ThunderPhone μαζί με τις ρυθμισμένες endpoint.headers σας:

Ο ίδιος βοηθός verify() λειτουργεί χωρίς αλλαγές, με δύο ιδιαιτερότητες:

  1. Τα εργαλεία GET / DELETE δεν έχουν σώμα. Τα ορίσματα μεταφέρονται ως παράμετροι ερωτήματος και η υπογραφή υπολογίζεται πάνω στην κενή συμβολοσειρά byte — επομένως verify(b"", sig, secret) (Python) ή verify(Buffer.alloc(0), sig, secret) (Node). Μην κάνετε hash τη συμβολοσειρά ερωτήματος.
  2. Οι οργανισμοί χωρίς ρυθμισμένο παλαιό webhook δεν έχουν μυστικό οργανισμού. Σε αυτή την περίπτωση, οι κλήσεις εργαλείων περιλαμβάνουν μόνο το X-ThunderPhone-Call-ID και καμία κεφαλίδα υπογραφής. Ρυθμίστε το παλαιό webhook (PUT /v1/webhook) για να λάβετε μυστικό υπογραφής ή πραγματοποιήστε έλεγχο ταυτότητας των κλήσεων εργαλείων με τη δική σας κεφαλίδα μέσω του endpoint.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)
    ...

Η αποστολή εργαλείων σε λειτουργία webhook (εργαλεία χωρίς endpoint, που παραδίδονται στο webhook του οργανισμού σας ως telephony.tool / web.tool) είναι ένα συνηθισμένο υπογεγραμμένο webhook — ισχύει η τυπική διαδικασία παραπάνω. Δείτε τα Εργαλεία συναρτήσεων για τις δύο μορφές αιτημάτων.

Συνήθη προβλήματα

Επανασειριοποίηση με προεπιλεγμένη μορφοποίηση

Η ανάλυση του σώματος και η επαναεξαγωγή του με τις προεπιλογές της βιβλιοθήκης JSON σας (κενά μετά από , / :, κλειδιά με σειρά εισαγωγής) παράγει διαφορετικά byte και καταστρέφει το HMAC. Επαληθεύστε το ακατέργαστο σώμα — ή, αν πρέπει να το επανασειριοποιήσετε, αντιστοιχίστε ακριβώς την κανονική μορφή μας: ταξινομημένα κλειδιά, συμπαγείς διαχωριστές, UTF-8.

Το framework αναλύει αυτόματα το JSON

Το middleware express.json() του Express καταναλώνει τη ροή του σώματος και χάνετε τα ακατέργαστα byte. Χρησιμοποιήστε το express.raw() ειδικά στη διαδρομή webhook ή αποθηκεύστε προσωρινά το ακατέργαστο σώμα σε ένα προ-middleware. Το ίδιο ισχύει για NestJS / Koa — ελέγξτε την τεκμηρίωσή τους για το «ακατέργαστο σώμα».

Σύγκριση μη ασφαλής ως προς τον χρόνο

Το expected === signature σε JS ή το expected == signature σε Python είναι συγκρίσεις μεταβλητού χρόνου. Χρησιμοποιήστε αντίστοιχα το crypto.timingSafeEqual ή το hmac.compare_digest. Η διαφορά στην απόδοση είναι μηδενική.

Λανθασμένο μυστικό για endpoint εργαλείων

Οι απευθείας κλήσεις σε endpoint εργαλείων υπογράφονται με το μυστικό webhook σε επίπεδο οργανισμού (GET /v1/webhook) — όχι με οποιοδήποτε μυστικό ανά endpoint από το /v1/developer/webhook-endpoints. Επαναχρησιμοποιήστε την ίδια συνάρτηση verify(), αλλά βεβαιωθείτε ότι τροφοδοτείτε με το μυστικό οργανισμού τις διαδρομές εργαλείων.

Κατακερματισμός του query string σε εργαλεία GET/DELETE

Για μεθόδους εργαλείων χωρίς σώμα, η υπογραφή καλύπτει την κενή ακολουθία byte, διατηρώντας μία καθολική συνταγή: εφαρμόστε HMAC στο ακατέργαστο σώμα του αιτήματος, όποιο κι αν είναι. Ο κατακερματισμός του URL ή του query string δεν θα αντιστοιχεί ποτέ.

Μη επιστροφή 401 σε ασυμφωνία

Η επιστροφή 200 σε αποτυχημένη επαλήθευση καθιστά τον χειριστή στόχο επανάληψης αιτημάτων. Να απαντάτε πάντα με μη-2xx αν η επαλήθευση αποτύχει.


Επόμενα βήματα