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