sayna-client 0.0.9__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sayna_client/__init__.py +86 -0
- sayna_client/client.py +919 -0
- sayna_client/errors.py +81 -0
- sayna_client/http_client.py +235 -0
- sayna_client/types.py +377 -0
- sayna_client/webhook_receiver.py +345 -0
- sayna_client-0.0.9.dist-info/METADATA +553 -0
- sayna_client-0.0.9.dist-info/RECORD +10 -0
- sayna_client-0.0.9.dist-info/WHEEL +5 -0
- sayna_client-0.0.9.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""Webhook receiver for verifying and parsing Sayna SIP webhooks."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any, Optional, Union
|
|
9
|
+
|
|
10
|
+
from pydantic import ValidationError
|
|
11
|
+
|
|
12
|
+
from sayna_client.errors import SaynaValidationError
|
|
13
|
+
from sayna_client.types import WebhookSIPOutput
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
#: Minimum required secret length in characters for security.
|
|
17
|
+
MIN_SECRET_LENGTH = 16
|
|
18
|
+
|
|
19
|
+
#: Maximum allowed time difference in seconds for replay protection.
|
|
20
|
+
#: Webhooks with timestamps outside this window will be rejected.
|
|
21
|
+
TIMESTAMP_TOLERANCE_SECONDS = 300 # 5 minutes
|
|
22
|
+
|
|
23
|
+
#: Expected length of HMAC-SHA256 signature in hex format
|
|
24
|
+
_SIGNATURE_HEX_LENGTH = 64
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class WebhookReceiver:
|
|
28
|
+
"""Receives and verifies cryptographically signed webhooks from Sayna SIP service.
|
|
29
|
+
|
|
30
|
+
This class handles the secure verification of webhook signatures using HMAC-SHA256,
|
|
31
|
+
validates timestamp freshness to prevent replay attacks, and parses the webhook
|
|
32
|
+
payload into a strongly-typed WebhookSIPOutput object.
|
|
33
|
+
|
|
34
|
+
Security Features:
|
|
35
|
+
- **HMAC-SHA256 Signature Verification**: Ensures webhook authenticity
|
|
36
|
+
- **Constant-Time Comparison**: Prevents timing attack vulnerabilities
|
|
37
|
+
- **Replay Protection**: 5-minute timestamp window prevents replay attacks
|
|
38
|
+
- **Strict Validation**: Comprehensive checks on all required fields
|
|
39
|
+
|
|
40
|
+
Examples:
|
|
41
|
+
Basic usage with Flask::
|
|
42
|
+
|
|
43
|
+
from flask import Flask, request, jsonify
|
|
44
|
+
from sayna_client import WebhookReceiver
|
|
45
|
+
|
|
46
|
+
app = Flask(__name__)
|
|
47
|
+
receiver = WebhookReceiver("your-secret-key-min-16-chars")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@app.route("/webhook", methods=["POST"])
|
|
51
|
+
def webhook():
|
|
52
|
+
try:
|
|
53
|
+
# Get raw body (CRITICAL: exact bytes as received)
|
|
54
|
+
body = request.get_data(as_text=True)
|
|
55
|
+
|
|
56
|
+
webhook = receiver.receive(request.headers, body)
|
|
57
|
+
|
|
58
|
+
print(f"Valid webhook received:")
|
|
59
|
+
print(f" From: {webhook.from_phone_number}")
|
|
60
|
+
print(f" To: {webhook.to_phone_number}")
|
|
61
|
+
print(f" Room: {webhook.room.name}")
|
|
62
|
+
print(f" SIP Host: {webhook.sip_host}")
|
|
63
|
+
print(f" Participant: {webhook.participant.identity}")
|
|
64
|
+
|
|
65
|
+
return jsonify({"received": True}), 200
|
|
66
|
+
except SaynaValidationError as error:
|
|
67
|
+
print(f"Webhook verification failed: {error}")
|
|
68
|
+
return jsonify({"error": "Invalid signature"}), 401
|
|
69
|
+
|
|
70
|
+
Using environment variable::
|
|
71
|
+
|
|
72
|
+
import os
|
|
73
|
+
from sayna_client import WebhookReceiver
|
|
74
|
+
|
|
75
|
+
# Set environment variable
|
|
76
|
+
os.environ["SAYNA_WEBHOOK_SECRET"] = "your-secret-key"
|
|
77
|
+
|
|
78
|
+
# Receiver automatically uses env variable
|
|
79
|
+
receiver = WebhookReceiver()
|
|
80
|
+
|
|
81
|
+
FastAPI example::
|
|
82
|
+
|
|
83
|
+
from fastapi import FastAPI, Request, HTTPException
|
|
84
|
+
from sayna_client import WebhookReceiver
|
|
85
|
+
|
|
86
|
+
app = FastAPI()
|
|
87
|
+
receiver = WebhookReceiver()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@app.post("/webhook")
|
|
91
|
+
async def webhook(request: Request):
|
|
92
|
+
try:
|
|
93
|
+
# Get raw body
|
|
94
|
+
body = await request.body()
|
|
95
|
+
body_str = body.decode("utf-8")
|
|
96
|
+
|
|
97
|
+
webhook = receiver.receive(dict(request.headers), body_str)
|
|
98
|
+
|
|
99
|
+
# Process webhook...
|
|
100
|
+
|
|
101
|
+
return {"received": True}
|
|
102
|
+
except SaynaValidationError as error:
|
|
103
|
+
raise HTTPException(status_code=401, detail=str(error))
|
|
104
|
+
|
|
105
|
+
Important Notes:
|
|
106
|
+
- **Raw Body Required**: You MUST pass the raw request body string, not the parsed
|
|
107
|
+
JSON object. The signature is computed over the exact bytes received, so any
|
|
108
|
+
formatting changes will cause verification to fail.
|
|
109
|
+
|
|
110
|
+
- **Case-Insensitive Headers**: Header names are case-insensitive in HTTP. This
|
|
111
|
+
class handles both ``X-Sayna-Signature`` and ``x-sayna-signature`` correctly.
|
|
112
|
+
|
|
113
|
+
- **Secret Security**: Never commit secrets to version control. Use environment
|
|
114
|
+
variables or a secret management system.
|
|
115
|
+
|
|
116
|
+
See Also:
|
|
117
|
+
WebhookSIPOutput: The validated webhook payload structure
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
def __init__(self, secret: Optional[str] = None) -> None:
|
|
121
|
+
"""Initialize a new webhook receiver with the specified signing secret.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
secret: HMAC signing secret (min 16 chars, 32+ recommended).
|
|
125
|
+
If not provided, uses SAYNA_WEBHOOK_SECRET environment variable.
|
|
126
|
+
|
|
127
|
+
Raises:
|
|
128
|
+
SaynaValidationError: If secret is missing or too short.
|
|
129
|
+
|
|
130
|
+
Examples:
|
|
131
|
+
With explicit secret::
|
|
132
|
+
|
|
133
|
+
receiver = WebhookReceiver("my-secret-key-at-least-16-chars")
|
|
134
|
+
|
|
135
|
+
From environment variable::
|
|
136
|
+
|
|
137
|
+
receiver = WebhookReceiver()
|
|
138
|
+
"""
|
|
139
|
+
effective_secret = secret or os.environ.get("SAYNA_WEBHOOK_SECRET")
|
|
140
|
+
|
|
141
|
+
if not effective_secret:
|
|
142
|
+
msg = (
|
|
143
|
+
"Webhook secret is required. Provide it as a constructor parameter "
|
|
144
|
+
"or set SAYNA_WEBHOOK_SECRET environment variable."
|
|
145
|
+
)
|
|
146
|
+
raise SaynaValidationError(msg)
|
|
147
|
+
|
|
148
|
+
trimmed_secret = effective_secret.strip()
|
|
149
|
+
|
|
150
|
+
if len(trimmed_secret) < MIN_SECRET_LENGTH:
|
|
151
|
+
msg = (
|
|
152
|
+
f"Webhook secret must be at least {MIN_SECRET_LENGTH} characters long. "
|
|
153
|
+
f"Received {len(trimmed_secret)} characters. "
|
|
154
|
+
f"Generate a secure secret with: openssl rand -hex 32"
|
|
155
|
+
)
|
|
156
|
+
raise SaynaValidationError(msg)
|
|
157
|
+
|
|
158
|
+
self._secret = trimmed_secret
|
|
159
|
+
|
|
160
|
+
def receive(self, headers: dict[str, Union[str, Any]], body: str) -> WebhookSIPOutput:
|
|
161
|
+
"""Verify and parse an incoming SIP webhook from Sayna.
|
|
162
|
+
|
|
163
|
+
This method performs the following security checks:
|
|
164
|
+
|
|
165
|
+
1. Validates presence of required headers
|
|
166
|
+
2. Verifies timestamp is within acceptable window (prevents replay attacks)
|
|
167
|
+
3. Computes HMAC-SHA256 signature over canonical string
|
|
168
|
+
4. Performs constant-time comparison to prevent timing attacks
|
|
169
|
+
5. Parses and validates the webhook payload structure
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
headers: HTTP request headers (case-insensitive dict or dict-like object)
|
|
173
|
+
body: Raw request body as string (not parsed JSON)
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Parsed and validated webhook payload.
|
|
177
|
+
|
|
178
|
+
Raises:
|
|
179
|
+
SaynaValidationError: If signature verification fails or payload is invalid.
|
|
180
|
+
|
|
181
|
+
Examples:
|
|
182
|
+
Flask example::
|
|
183
|
+
|
|
184
|
+
@app.route("/webhook", methods=["POST"])
|
|
185
|
+
def webhook():
|
|
186
|
+
body = request.get_data(as_text=True)
|
|
187
|
+
webhook = receiver.receive(request.headers, body)
|
|
188
|
+
# webhook is now a validated WebhookSIPOutput object
|
|
189
|
+
return jsonify({"received": True})
|
|
190
|
+
|
|
191
|
+
Django example::
|
|
192
|
+
|
|
193
|
+
from django.http import JsonResponse
|
|
194
|
+
from django.views.decorators.csrf import csrf_exempt
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@csrf_exempt
|
|
198
|
+
def webhook(request):
|
|
199
|
+
body = request.body.decode("utf-8")
|
|
200
|
+
webhook_data = receiver.receive(request.headers, body)
|
|
201
|
+
return JsonResponse({"received": True})
|
|
202
|
+
"""
|
|
203
|
+
# Normalize headers to lowercase for case-insensitive lookup
|
|
204
|
+
normalized_headers = self._normalize_headers(headers)
|
|
205
|
+
|
|
206
|
+
# Extract required headers
|
|
207
|
+
signature = self._get_required_header(normalized_headers, "x-sayna-signature")
|
|
208
|
+
timestamp = self._get_required_header(normalized_headers, "x-sayna-timestamp")
|
|
209
|
+
event_id = self._get_required_header(normalized_headers, "x-sayna-event-id")
|
|
210
|
+
|
|
211
|
+
# Parse and validate signature format
|
|
212
|
+
if not signature.startswith("v1="):
|
|
213
|
+
msg = f"Invalid signature format. Expected 'v1=<hex>' but got: {signature[:10]}..."
|
|
214
|
+
raise SaynaValidationError(msg)
|
|
215
|
+
signature_hex = signature[3:]
|
|
216
|
+
|
|
217
|
+
# Validate signature is valid hex (64 chars for SHA256)
|
|
218
|
+
if len(signature_hex) != _SIGNATURE_HEX_LENGTH or not all(
|
|
219
|
+
c in "0123456789abcdefABCDEF" for c in signature_hex
|
|
220
|
+
):
|
|
221
|
+
msg = f"Invalid signature: must be {_SIGNATURE_HEX_LENGTH} hex characters (HMAC-SHA256)"
|
|
222
|
+
raise SaynaValidationError(msg)
|
|
223
|
+
|
|
224
|
+
# Validate and check timestamp
|
|
225
|
+
self._validate_timestamp(timestamp)
|
|
226
|
+
|
|
227
|
+
# Build canonical string for signature verification
|
|
228
|
+
canonical = f"v1:{timestamp}:{event_id}:{body}"
|
|
229
|
+
|
|
230
|
+
# Compute expected signature
|
|
231
|
+
expected_signature = hmac.new(
|
|
232
|
+
self._secret.encode("utf-8"), canonical.encode("utf-8"), hashlib.sha256
|
|
233
|
+
).hexdigest()
|
|
234
|
+
|
|
235
|
+
# Constant-time comparison to prevent timing attacks
|
|
236
|
+
if not hmac.compare_digest(signature_hex.lower(), expected_signature):
|
|
237
|
+
msg = (
|
|
238
|
+
"Signature verification failed. The webhook may have been tampered "
|
|
239
|
+
"with or the secret is incorrect."
|
|
240
|
+
)
|
|
241
|
+
raise SaynaValidationError(msg)
|
|
242
|
+
|
|
243
|
+
# Parse and validate the webhook payload using Pydantic
|
|
244
|
+
return self._parse_and_validate_payload(body)
|
|
245
|
+
|
|
246
|
+
def _normalize_headers(self, headers: dict[str, Any]) -> dict[str, str]:
|
|
247
|
+
"""Normalize HTTP headers to lowercase for case-insensitive access.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
headers: Original headers dict (may contain non-string values)
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
Normalized headers dict with lowercase keys and string values
|
|
254
|
+
"""
|
|
255
|
+
normalized: dict[str, str] = {}
|
|
256
|
+
|
|
257
|
+
for key, value in headers.items():
|
|
258
|
+
# Convert key to lowercase and value to string
|
|
259
|
+
if value is not None:
|
|
260
|
+
normalized[key.lower()] = str(value)
|
|
261
|
+
|
|
262
|
+
return normalized
|
|
263
|
+
|
|
264
|
+
def _get_required_header(self, headers: dict[str, str], name: str) -> str:
|
|
265
|
+
"""Retrieve a required header value or raise a validation error.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
headers: Normalized headers dict
|
|
269
|
+
name: Header name (lowercase)
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
Header value
|
|
273
|
+
|
|
274
|
+
Raises:
|
|
275
|
+
SaynaValidationError: If header is missing
|
|
276
|
+
"""
|
|
277
|
+
value = headers.get(name.lower())
|
|
278
|
+
|
|
279
|
+
if not value:
|
|
280
|
+
msg = f"Missing required header: {name}"
|
|
281
|
+
raise SaynaValidationError(msg)
|
|
282
|
+
|
|
283
|
+
return value
|
|
284
|
+
|
|
285
|
+
def _validate_timestamp(self, timestamp_str: str) -> None:
|
|
286
|
+
"""Validate the timestamp is within the acceptable window.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
timestamp_str: Timestamp as string (Unix seconds)
|
|
290
|
+
|
|
291
|
+
Raises:
|
|
292
|
+
SaynaValidationError: If timestamp is invalid or outside window
|
|
293
|
+
"""
|
|
294
|
+
# Parse timestamp
|
|
295
|
+
try:
|
|
296
|
+
timestamp = int(timestamp_str)
|
|
297
|
+
except ValueError as e:
|
|
298
|
+
msg = f"Invalid timestamp format: expected Unix seconds but got '{timestamp_str}'"
|
|
299
|
+
raise SaynaValidationError(msg) from e
|
|
300
|
+
|
|
301
|
+
# Check if timestamp is within acceptable range
|
|
302
|
+
now = int(time.time())
|
|
303
|
+
diff = abs(now - timestamp)
|
|
304
|
+
|
|
305
|
+
if diff > TIMESTAMP_TOLERANCE_SECONDS:
|
|
306
|
+
msg = (
|
|
307
|
+
f"Timestamp outside replay protection window. "
|
|
308
|
+
f"Difference: {diff} seconds (max allowed: {TIMESTAMP_TOLERANCE_SECONDS}). "
|
|
309
|
+
f"This webhook may be a replay attack or there may be significant clock skew."
|
|
310
|
+
)
|
|
311
|
+
raise SaynaValidationError(msg)
|
|
312
|
+
|
|
313
|
+
def _parse_and_validate_payload(self, body: str) -> WebhookSIPOutput:
|
|
314
|
+
"""Parse and validate the webhook payload structure using Pydantic.
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
body: Raw JSON body string
|
|
318
|
+
|
|
319
|
+
Returns:
|
|
320
|
+
Validated WebhookSIPOutput object
|
|
321
|
+
|
|
322
|
+
Raises:
|
|
323
|
+
SaynaValidationError: If JSON is invalid or validation fails
|
|
324
|
+
"""
|
|
325
|
+
# Parse JSON
|
|
326
|
+
try:
|
|
327
|
+
payload = json.loads(body)
|
|
328
|
+
except json.JSONDecodeError as error:
|
|
329
|
+
msg = f"Invalid JSON payload: {error}"
|
|
330
|
+
raise SaynaValidationError(msg) from error
|
|
331
|
+
|
|
332
|
+
# Validate using Pydantic model
|
|
333
|
+
try:
|
|
334
|
+
return WebhookSIPOutput(**payload)
|
|
335
|
+
except ValidationError as error:
|
|
336
|
+
# Extract first error message for clearer feedback
|
|
337
|
+
errors = error.errors()
|
|
338
|
+
if errors:
|
|
339
|
+
first_error = errors[0]
|
|
340
|
+
field = ".".join(str(loc) for loc in first_error["loc"])
|
|
341
|
+
msg = first_error["msg"]
|
|
342
|
+
msg = f"Webhook payload validation failed: {field}: {msg}"
|
|
343
|
+
raise SaynaValidationError(msg) from error
|
|
344
|
+
msg = f"Webhook payload validation failed: {error}"
|
|
345
|
+
raise SaynaValidationError(msg) from error
|