mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-07 07:55:16 +02:00
Verify Telnyx webhook signatures (#271)
* Verify Telnyx webhook signatures * feat: harden telnyx webhook signature verification --------- Co-authored-by: a692570 <a692570@users.noreply.github.com> Co-authored-by: Sabiha Khan <sabihak89@gmail.com>
This commit is contained in:
parent
9389340807
commit
137b5e9f89
5 changed files with 509 additions and 24 deletions
|
|
@ -4,14 +4,27 @@ Uses the Telnyx Call Control API v2 for outbound calling with
|
|||
inline WebSocket media streaming.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import random
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
||||
|
||||
import aiohttp
|
||||
import nacl.exceptions
|
||||
import nacl.signing
|
||||
from fastapi import HTTPException, WebSocketDisconnect
|
||||
from loguru import logger
|
||||
|
||||
# 5-min replay window — matches Telnyx SDKs (Python/Node/Go/Ruby/PHP);
|
||||
# Source: github.com/team-telnyx/telnyx-python src/telnyx/lib/webhook_verification.py
|
||||
TELNYX_TIMESTAMP_TOLERANCE_SECONDS = 300
|
||||
|
||||
# Ed25519 sizes per RFC 8032; Telnyx SDKs check these for clearer errors than PyNaCl.
|
||||
TELNYX_PUBLIC_KEY_BYTES = 32
|
||||
TELNYX_SIGNATURE_BYTES = 64
|
||||
|
||||
from api.enums import WorkflowRunMode
|
||||
from api.services.telephony.base import (
|
||||
CallInitiationResult,
|
||||
|
|
@ -34,6 +47,13 @@ def normalize_event_type(event_type: str) -> str:
|
|||
return (event_type or "").replace("_", ".")
|
||||
|
||||
|
||||
def _get_header(headers: Dict[str, str], name: str) -> str:
|
||||
for key, value in headers.items():
|
||||
if key.lower() == name:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
class TelnyxProvider(TelephonyProvider):
|
||||
"""
|
||||
Telnyx implementation of TelephonyProvider.
|
||||
|
|
@ -168,13 +188,83 @@ class TelnyxProvider(TelephonyProvider):
|
|||
async def verify_webhook_signature(
|
||||
self, url: str, params: Dict[str, Any], signature: str
|
||||
) -> bool:
|
||||
"""Required by the abstract interface but not actively called for Telnyx.
|
||||
"""Verify a Telnyx Ed25519 webhook signature.
|
||||
|
||||
Telnyx webhook signature verification uses Ed25519 (via the
|
||||
telnyx-signature-ed25519 header). This can be implemented in the
|
||||
future using the Telnyx SDK if needed.
|
||||
Telnyx signs ``{timestamp}|{json_payload}`` and sends the signature in
|
||||
``telnyx-signature-ed25519``. The public key is read from provider
|
||||
configuration, not from the request. ``url`` is unused — Telnyx does
|
||||
not sign the request URL; the parameter exists to satisfy the base
|
||||
class interface.
|
||||
|
||||
Docs:
|
||||
https://developers.telnyx.com/development/api-fundamentals/webhooks/receiving-webhooks
|
||||
"""
|
||||
return True
|
||||
timestamp = params.get("telnyx_timestamp") or params.get("timestamp")
|
||||
raw_body = params.get("_raw_body", "")
|
||||
|
||||
if not signature:
|
||||
logger.warning("Telnyx webhook missing telnyx-signature-ed25519 header")
|
||||
return False
|
||||
if not timestamp:
|
||||
logger.warning("Telnyx webhook missing telnyx-timestamp header")
|
||||
return False
|
||||
|
||||
if not self.webhook_public_key:
|
||||
logger.error("Missing Telnyx webhook_public_key configuration")
|
||||
return False
|
||||
|
||||
try:
|
||||
ts_int = int(timestamp)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(f"Invalid Telnyx webhook timestamp format: {timestamp!r}")
|
||||
return False
|
||||
|
||||
if abs(time.time() - ts_int) > TELNYX_TIMESTAMP_TOLERANCE_SECONDS:
|
||||
logger.warning(
|
||||
f"Telnyx webhook timestamp outside "
|
||||
f"{TELNYX_TIMESTAMP_TOLERANCE_SECONDS}s tolerance: "
|
||||
f"timestamp={ts_int}, now={int(time.time())}"
|
||||
)
|
||||
return False
|
||||
|
||||
if isinstance(raw_body, bytes):
|
||||
raw_body = raw_body.decode("utf-8")
|
||||
|
||||
try:
|
||||
signature_bytes = base64.b64decode(signature, validate=True)
|
||||
except (binascii.Error, ValueError) as e:
|
||||
logger.warning(f"Telnyx webhook signature not valid base64: {e}")
|
||||
return False
|
||||
|
||||
try:
|
||||
public_key_bytes = base64.b64decode(
|
||||
self.webhook_public_key.strip(), validate=True
|
||||
)
|
||||
except (binascii.Error, ValueError) as e:
|
||||
logger.error(f"Telnyx webhook_public_key not valid base64: {e}")
|
||||
return False
|
||||
|
||||
if len(public_key_bytes) != TELNYX_PUBLIC_KEY_BYTES:
|
||||
logger.error(
|
||||
f"Telnyx webhook_public_key wrong length: expected "
|
||||
f"{TELNYX_PUBLIC_KEY_BYTES}, got {len(public_key_bytes)}"
|
||||
)
|
||||
return False
|
||||
|
||||
if len(signature_bytes) != TELNYX_SIGNATURE_BYTES:
|
||||
logger.warning(
|
||||
f"Telnyx webhook signature wrong length: expected "
|
||||
f"{TELNYX_SIGNATURE_BYTES}, got {len(signature_bytes)}"
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
verify_key = nacl.signing.VerifyKey(public_key_bytes)
|
||||
signed_payload = f"{timestamp}|{raw_body}".encode("utf-8")
|
||||
verify_key.verify(signed_payload, signature_bytes)
|
||||
return True
|
||||
except nacl.exceptions.BadSignatureError:
|
||||
return False
|
||||
|
||||
async def get_webhook_response(
|
||||
self, workflow_id: int, user_id: int, workflow_run_id: int
|
||||
|
|
@ -420,9 +510,9 @@ class TelnyxProvider(TelephonyProvider):
|
|||
return NormalizedInboundData(
|
||||
provider=TelnyxProvider.PROVIDER_NAME,
|
||||
call_id=payload.get("call_control_id", ""),
|
||||
from_number=normalize_telephony_address(from_raw).canonical
|
||||
if from_raw
|
||||
else "",
|
||||
from_number=(
|
||||
normalize_telephony_address(from_raw).canonical if from_raw else ""
|
||||
),
|
||||
to_number=normalize_telephony_address(to_raw).canonical if to_raw else "",
|
||||
direction=direction,
|
||||
call_status=normalize_event_type(data.get("event_type", "")),
|
||||
|
|
@ -444,11 +534,14 @@ class TelnyxProvider(TelephonyProvider):
|
|||
headers: Dict[str, str],
|
||||
body: str = "",
|
||||
) -> bool:
|
||||
"""Required by the abstract interface. Telnyx signature verification
|
||||
(Ed25519 via ``telnyx-signature-ed25519``) is not yet implemented —
|
||||
accepts all inbound webhooks for now.
|
||||
"""
|
||||
return True
|
||||
"""Verify the signature of an inbound Telnyx webhook."""
|
||||
signature = _get_header(headers, "telnyx-signature-ed25519")
|
||||
timestamp = _get_header(headers, "telnyx-timestamp")
|
||||
return await self.verify_webhook_signature(
|
||||
url,
|
||||
{"telnyx_timestamp": timestamp, "_raw_body": body},
|
||||
signature,
|
||||
)
|
||||
|
||||
async def configure_inbound(
|
||||
self, address: str, webhook_url: Optional[str]
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ provider registry — see ProviderSpec.router.
|
|||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from loguru import logger
|
||||
from pipecat.utils.run_context import set_current_run_id
|
||||
|
||||
|
|
@ -56,10 +56,15 @@ async def handle_telnyx_events(
|
|||
"""
|
||||
set_current_run_id(workflow_run_id)
|
||||
|
||||
event_data = await request.json()
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Received Telnyx event: {json.dumps(event_data)}"
|
||||
)
|
||||
try:
|
||||
raw_body = (await request.body()).decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
logger.warning(
|
||||
f"[run {workflow_run_id}] Telnyx webhook body is not valid UTF-8"
|
||||
)
|
||||
raise HTTPException(status_code=400, detail="Webhook body is not valid UTF-8")
|
||||
|
||||
event_data = json.loads(raw_body)
|
||||
|
||||
# Extract event type from Telnyx envelope. Telnyx sometimes delivers the
|
||||
# type with underscores (``streaming_started``) instead of dots
|
||||
|
|
@ -67,26 +72,48 @@ async def handle_telnyx_events(
|
|||
data = event_data.get("data", {})
|
||||
event_type = normalize_event_type(data.get("event_type", ""))
|
||||
|
||||
# Skip streaming events — they're informational only
|
||||
if event_type in ("streaming.started", "streaming.stopped"):
|
||||
logger.debug(f"[run {workflow_run_id}] Telnyx streaming event: {event_type}")
|
||||
return {"status": "success"}
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Received Telnyx event: event_type={event_type}"
|
||||
)
|
||||
logger.debug(f"[run {workflow_run_id}] Telnyx event body: {json.dumps(event_data)}")
|
||||
|
||||
# Get workflow run and provider
|
||||
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
|
||||
if not workflow_run:
|
||||
logger.warning(f"Workflow run {workflow_run_id} not found for Telnyx event")
|
||||
return {"status": "ignored", "reason": "workflow_run_not_found"}
|
||||
raise HTTPException(status_code=404, detail="Workflow run not found")
|
||||
|
||||
workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id)
|
||||
if not workflow:
|
||||
logger.warning(f"Workflow {workflow_run.workflow_id} not found")
|
||||
return {"status": "ignored", "reason": "workflow_not_found"}
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
|
||||
provider = await get_telephony_provider_for_run(
|
||||
workflow_run, workflow.organization_id
|
||||
)
|
||||
|
||||
signature_valid = await provider.verify_inbound_signature(
|
||||
"", event_data, dict(request.headers), raw_body
|
||||
)
|
||||
if not signature_valid:
|
||||
logger.warning(
|
||||
f"[run {workflow_run_id}] Invalid Telnyx webhook signature "
|
||||
f"(event_type={event_type}, "
|
||||
f"timestamp={request.headers.get('telnyx-timestamp')}, "
|
||||
f"body_len={len(raw_body)})"
|
||||
)
|
||||
raise HTTPException(status_code=401, detail="Invalid webhook signature")
|
||||
|
||||
logger.debug(
|
||||
f"[run {workflow_run_id}] Telnyx webhook signature verified "
|
||||
f"(event_type={event_type})"
|
||||
)
|
||||
|
||||
# Skip streaming events. They are informational only, but still verified.
|
||||
if event_type in ("streaming.started", "streaming.stopped"):
|
||||
logger.debug(f"[run {workflow_run_id}] Telnyx streaming event: {event_type}")
|
||||
return {"status": "success"}
|
||||
|
||||
# Parse the callback data into generic format
|
||||
parsed_data = provider.parse_status_callback(event_data)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue