webex-message-handler 0.6.7__tar.gz → 0.6.9__tar.gz
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.
- {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/PKG-INFO +16 -1
- {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/README.md +15 -0
- {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/pyproject.toml +1 -1
- {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/src/webex_message_handler/__init__.py +5 -0
- {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/src/webex_message_handler/handler.py +19 -1
- {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/src/webex_message_handler/kms_client.py +108 -58
- {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/src/webex_message_handler/types.py +26 -0
- {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/src/webex_message_handler/url_validation.py +2 -2
- webex_message_handler-0.6.9/tests/test_kms_client.py +103 -0
- {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/.gitignore +0 -0
- {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/API.md +0 -0
- {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/LICENSE +0 -0
- {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/examples/basic_bot.py +0 -0
- {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/src/webex_message_handler/device_manager.py +0 -0
- {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/src/webex_message_handler/errors.py +0 -0
- {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/src/webex_message_handler/id_utils.py +0 -0
- {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/src/webex_message_handler/logger.py +0 -0
- {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/src/webex_message_handler/mention_parser.py +0 -0
- {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/src/webex_message_handler/mercury_socket.py +0 -0
- {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/src/webex_message_handler/message_decryptor.py +0 -0
- {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/test-proxy.py +0 -0
- {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/tests/__init__.py +0 -0
- {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/tests/conftest.py +0 -0
- {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/tests/test_device_manager.py +0 -0
- {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/tests/test_handler.py +0 -0
- {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/tests/test_id_utils.py +0 -0
- {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/tests/test_integration.py +0 -0
- {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/tests/test_message_decryptor.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: webex-message-handler
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.9
|
|
4
4
|
Summary: Lightweight Webex Mercury WebSocket + KMS decryption for receiving bot messages without the full Webex SDK
|
|
5
5
|
Project-URL: Homepage, https://github.com/3rg0n/webex-message-handler
|
|
6
6
|
Project-URL: Repository, https://github.com/3rg0n/webex-message-handler
|
|
@@ -187,6 +187,20 @@ async def on_message(msg):
|
|
|
187
187
|
|
|
188
188
|
Resource types: `"MESSAGE"`, `"PEOPLE"`, `"ROOM"`.
|
|
189
189
|
|
|
190
|
+
## Delivery Guarantees
|
|
191
|
+
|
|
192
|
+
This library provides **at-most-once** delivery semantics:
|
|
193
|
+
|
|
194
|
+
- Mercury WebSocket acknowledges messages at the protocol level on receipt, before decryption or consumer delivery.
|
|
195
|
+
- If decryption fails (e.g., KMS outage) or your callback throws an error, the message is **not redelivered**.
|
|
196
|
+
- Mercury does not support application-level ACK/NACK — this is an inherent constraint of the Webex platform.
|
|
197
|
+
|
|
198
|
+
**For consumers requiring stronger guarantees:**
|
|
199
|
+
|
|
200
|
+
- Wrap your callback with a persistent queue (e.g., database, Redis, or message broker) to ensure processing completes.
|
|
201
|
+
- Use the `error` event to detect and log decryption failures.
|
|
202
|
+
- The KMS circuit breaker (v0.6.9+) prevents 30-second stalls during KMS outages by failing fast after 3 consecutive failures.
|
|
203
|
+
|
|
190
204
|
## API Reference
|
|
191
205
|
|
|
192
206
|
### `WebexMessageHandler`
|
|
@@ -211,6 +225,7 @@ WebexMessageHandler(config: WebexMessageHandlerConfig)
|
|
|
211
225
|
| `pong_timeout` | `float` | `14.0` | Pong response timeout (seconds) |
|
|
212
226
|
| `reconnect_backoff_max` | `float` | `32.0` | Max reconnect backoff (seconds) |
|
|
213
227
|
| `max_reconnect_attempts` | `int` | `10` | Max reconnect attempts |
|
|
228
|
+
| `metrics_callback` | `MetricsCallback \| None` | `None` | Optional callback for timing metrics (`connect`, `decrypt` events) |
|
|
214
229
|
|
|
215
230
|
#### Methods
|
|
216
231
|
|
|
@@ -155,6 +155,20 @@ async def on_message(msg):
|
|
|
155
155
|
|
|
156
156
|
Resource types: `"MESSAGE"`, `"PEOPLE"`, `"ROOM"`.
|
|
157
157
|
|
|
158
|
+
## Delivery Guarantees
|
|
159
|
+
|
|
160
|
+
This library provides **at-most-once** delivery semantics:
|
|
161
|
+
|
|
162
|
+
- Mercury WebSocket acknowledges messages at the protocol level on receipt, before decryption or consumer delivery.
|
|
163
|
+
- If decryption fails (e.g., KMS outage) or your callback throws an error, the message is **not redelivered**.
|
|
164
|
+
- Mercury does not support application-level ACK/NACK — this is an inherent constraint of the Webex platform.
|
|
165
|
+
|
|
166
|
+
**For consumers requiring stronger guarantees:**
|
|
167
|
+
|
|
168
|
+
- Wrap your callback with a persistent queue (e.g., database, Redis, or message broker) to ensure processing completes.
|
|
169
|
+
- Use the `error` event to detect and log decryption failures.
|
|
170
|
+
- The KMS circuit breaker (v0.6.9+) prevents 30-second stalls during KMS outages by failing fast after 3 consecutive failures.
|
|
171
|
+
|
|
158
172
|
## API Reference
|
|
159
173
|
|
|
160
174
|
### `WebexMessageHandler`
|
|
@@ -179,6 +193,7 @@ WebexMessageHandler(config: WebexMessageHandlerConfig)
|
|
|
179
193
|
| `pong_timeout` | `float` | `14.0` | Pong response timeout (seconds) |
|
|
180
194
|
| `reconnect_backoff_max` | `float` | `32.0` | Max reconnect backoff (seconds) |
|
|
181
195
|
| `max_reconnect_attempts` | `int` | `10` | Max reconnect attempts |
|
|
196
|
+
| `metrics_callback` | `MetricsCallback \| None` | `None` | Optional callback for timing metrics (`connect`, `decrypt` events) |
|
|
182
197
|
|
|
183
198
|
#### Methods
|
|
184
199
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "webex-message-handler"
|
|
7
|
-
version = "0.6.
|
|
7
|
+
version = "0.6.9"
|
|
8
8
|
description = "Lightweight Webex Mercury WebSocket + KMS decryption for receiving bot messages without the full Webex SDK"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
{webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/src/webex_message_handler/__init__.py
RENAMED
|
@@ -28,6 +28,8 @@ from .types import (
|
|
|
28
28
|
HandlerStatus,
|
|
29
29
|
InjectedWebSocket,
|
|
30
30
|
MembershipActivity,
|
|
31
|
+
MetricsCallback,
|
|
32
|
+
MetricsEvent,
|
|
31
33
|
MercuryActivity,
|
|
32
34
|
MercuryActor,
|
|
33
35
|
MercuryEnvelope,
|
|
@@ -88,4 +90,7 @@ __all__ = [
|
|
|
88
90
|
"FetchFunction",
|
|
89
91
|
"InjectedWebSocket",
|
|
90
92
|
"WebSocketFactory",
|
|
93
|
+
# Metrics types
|
|
94
|
+
"MetricsEvent",
|
|
95
|
+
"MetricsCallback",
|
|
91
96
|
]
|
{webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/src/webex_message_handler/handler.py
RENAMED
|
@@ -27,6 +27,7 @@ from .types import (
|
|
|
27
27
|
HandlerStatus,
|
|
28
28
|
InjectedWebSocket,
|
|
29
29
|
MembershipActivity,
|
|
30
|
+
MetricsEvent,
|
|
30
31
|
MercuryActivity,
|
|
31
32
|
RoomActivity,
|
|
32
33
|
WebexMessageHandlerConfig,
|
|
@@ -125,6 +126,7 @@ class WebexMessageHandler:
|
|
|
125
126
|
|
|
126
127
|
self._ignore_self_messages = config.ignore_self_messages
|
|
127
128
|
self._bot_person_id: str | None = None
|
|
129
|
+
self._metrics_callback = config.metrics_callback
|
|
128
130
|
|
|
129
131
|
self._kms_client: KmsClient | None = None
|
|
130
132
|
self._message_decryptor: MessageDecryptor | None = None
|
|
@@ -152,6 +154,12 @@ class WebexMessageHandler:
|
|
|
152
154
|
|
|
153
155
|
self._setup_mercury_listeners()
|
|
154
156
|
|
|
157
|
+
def _report_metric(self, name: str, start_time: float, success: bool, metadata: dict[str, str] | None = None) -> None:
|
|
158
|
+
"""Report a timing metric if callback is set."""
|
|
159
|
+
if self._metrics_callback:
|
|
160
|
+
duration_ms = (time.monotonic() - start_time) * 1000
|
|
161
|
+
self._metrics_callback(MetricsEvent(name=name, duration_ms=duration_ms, success=success, metadata=metadata))
|
|
162
|
+
|
|
155
163
|
def _create_native_http_adapter(
|
|
156
164
|
self, connector: aiohttp.BaseConnector | None
|
|
157
165
|
) -> FetchFunction:
|
|
@@ -275,6 +283,7 @@ class WebexMessageHandler:
|
|
|
275
283
|
self._logger.info("Connecting to Webex...")
|
|
276
284
|
self._connecting = True
|
|
277
285
|
|
|
286
|
+
connect_start = time.monotonic()
|
|
278
287
|
try:
|
|
279
288
|
# Step 1: Register device with WDM
|
|
280
289
|
self._registration = await self._device_manager.register(self._token)
|
|
@@ -314,10 +323,12 @@ class WebexMessageHandler:
|
|
|
314
323
|
self._connecting = False
|
|
315
324
|
self._connected = True
|
|
316
325
|
self._logger.info("Connected to Webex")
|
|
326
|
+
self._report_metric("connect", connect_start, True)
|
|
317
327
|
self._emit("connected")
|
|
318
328
|
|
|
319
329
|
except Exception:
|
|
320
330
|
self._connecting = False
|
|
331
|
+
self._report_metric("connect", connect_start, False)
|
|
321
332
|
raise
|
|
322
333
|
|
|
323
334
|
async def disconnect(self) -> None:
|
|
@@ -468,7 +479,14 @@ class WebexMessageHandler:
|
|
|
468
479
|
self._logger.warning("Received activity but decryptor not initialized")
|
|
469
480
|
return
|
|
470
481
|
|
|
471
|
-
|
|
482
|
+
decrypt_start = time.monotonic()
|
|
483
|
+
try:
|
|
484
|
+
decrypted = await self._message_decryptor.decrypt_activity(activity)
|
|
485
|
+
self._report_metric("decrypt", decrypt_start, True)
|
|
486
|
+
except Exception:
|
|
487
|
+
self._report_metric("decrypt", decrypt_start, False)
|
|
488
|
+
raise
|
|
489
|
+
|
|
472
490
|
mentions = parse_mentions(decrypted.object.content)
|
|
473
491
|
message = DecryptedMessage(
|
|
474
492
|
id=decrypted.id,
|
{webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/src/webex_message_handler/kms_client.py
RENAMED
|
@@ -13,6 +13,7 @@ from __future__ import annotations
|
|
|
13
13
|
|
|
14
14
|
import asyncio
|
|
15
15
|
import json
|
|
16
|
+
import time
|
|
16
17
|
import uuid
|
|
17
18
|
from datetime import datetime, timedelta, timezone
|
|
18
19
|
from typing import Any
|
|
@@ -26,6 +27,8 @@ from .types import FetchFunction, FetchRequest
|
|
|
26
27
|
from .url_validation import validate_webex_url
|
|
27
28
|
|
|
28
29
|
KMS_RESPONSE_TIMEOUT = 30.0 # seconds
|
|
30
|
+
CB_FAILURE_THRESHOLD = 3
|
|
31
|
+
CB_COOLDOWN_SECONDS = 30.0
|
|
29
32
|
|
|
30
33
|
|
|
31
34
|
class KmsClient:
|
|
@@ -64,6 +67,11 @@ class KmsClient:
|
|
|
64
67
|
# Lock to serialize KMS HTTP requests
|
|
65
68
|
self._kms_request_lock = asyncio.Lock()
|
|
66
69
|
|
|
70
|
+
# Circuit breaker state
|
|
71
|
+
self._cb_state: str = "closed" # "closed", "open", "half_open"
|
|
72
|
+
self._cb_failures: int = 0
|
|
73
|
+
self._cb_last_failure: float = 0.0
|
|
74
|
+
|
|
67
75
|
def handle_kms_message(self, data: dict[str, Any]) -> None:
|
|
68
76
|
"""Handle a KMS response that arrived via Mercury WebSocket.
|
|
69
77
|
|
|
@@ -106,8 +114,8 @@ class KmsClient:
|
|
|
106
114
|
kms_details = await response.json()
|
|
107
115
|
|
|
108
116
|
self._kms_cluster = kms_details["kmsCluster"]
|
|
109
|
-
# Validate KMS cluster URL
|
|
110
|
-
validate_webex_url(self._kms_cluster, "
|
|
117
|
+
# Validate KMS cluster URL — Webex returns kms:// scheme for this field
|
|
118
|
+
validate_webex_url(self._kms_cluster, "kms")
|
|
111
119
|
|
|
112
120
|
rsa_public_key_raw = kms_details["rsaPublicKey"]
|
|
113
121
|
if isinstance(rsa_public_key_raw, str):
|
|
@@ -231,69 +239,91 @@ class KmsClient:
|
|
|
231
239
|
if not self._initialized or not self._ephemeral_key:
|
|
232
240
|
raise KmsError("KMS context not initialized")
|
|
233
241
|
|
|
234
|
-
#
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
"
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
or response_data.get("key", {}).get("jwk")
|
|
268
|
-
)
|
|
269
|
-
if (
|
|
270
|
-
not key_data
|
|
271
|
-
and "body" in response_data
|
|
272
|
-
and "key" in response_data["body"]
|
|
273
|
-
):
|
|
274
|
-
key_obj = response_data["body"]["key"]
|
|
275
|
-
key_data = key_obj["jwk"] if isinstance(key_obj, dict) and "jwk" in key_obj else key_obj
|
|
276
|
-
|
|
277
|
-
if not key_data:
|
|
278
|
-
raise KmsError("No key found in KMS response")
|
|
279
|
-
|
|
280
|
-
if isinstance(key_data, str):
|
|
281
|
-
key_data = json.loads(key_data)
|
|
242
|
+
# Check circuit breaker state
|
|
243
|
+
if self._cb_state == "open":
|
|
244
|
+
if time.monotonic() - self._cb_last_failure >= CB_COOLDOWN_SECONDS:
|
|
245
|
+
self._cb_state = "half_open"
|
|
246
|
+
self._logger.info("KMS circuit breaker transitioned to half_open — attempting recovery")
|
|
247
|
+
else:
|
|
248
|
+
raise KmsError("KMS circuit breaker is open — failing fast")
|
|
249
|
+
|
|
250
|
+
# Build retrieve request with retry logic
|
|
251
|
+
max_attempts = 2
|
|
252
|
+
for attempt in range(1, max_attempts + 1):
|
|
253
|
+
try:
|
|
254
|
+
request_id = str(uuid.uuid4())
|
|
255
|
+
retrieve_body = {
|
|
256
|
+
"client": {
|
|
257
|
+
"clientId": self._device_url,
|
|
258
|
+
"credential": {
|
|
259
|
+
"userId": self._user_id,
|
|
260
|
+
"bearer": self._token,
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
"method": "retrieve",
|
|
264
|
+
"uri": key_uri,
|
|
265
|
+
"requestId": request_id,
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
# Wrap with ephemeral key (dir + A256GCM — key is CEK directly)
|
|
269
|
+
wrapped = _jwe_encrypt(
|
|
270
|
+
plaintext=json.dumps(retrieve_body).encode(),
|
|
271
|
+
key=self._ephemeral_key,
|
|
272
|
+
alg="dir",
|
|
273
|
+
enc="A256GCM",
|
|
274
|
+
)
|
|
282
275
|
|
|
283
|
-
|
|
276
|
+
# POST and wait for Mercury response
|
|
277
|
+
wrapped_response = await self._send_kms_request(request_id, wrapped)
|
|
284
278
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
self._key_cache.clear()
|
|
279
|
+
# Unwrap response
|
|
280
|
+
response_body = _unwrap_kms_response(wrapped_response, self._ephemeral_key)
|
|
281
|
+
response_data = json.loads(response_body)
|
|
289
282
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
283
|
+
# Extract the content key
|
|
284
|
+
key_data = (
|
|
285
|
+
response_data.get("body", {}).get("key", {}).get("jwk")
|
|
286
|
+
or response_data.get("key", {}).get("jwk")
|
|
287
|
+
)
|
|
288
|
+
if (
|
|
289
|
+
not key_data
|
|
290
|
+
and "body" in response_data
|
|
291
|
+
and "key" in response_data["body"]
|
|
292
|
+
):
|
|
293
|
+
key_obj = response_data["body"]["key"]
|
|
294
|
+
key_data = key_obj["jwk"] if isinstance(key_obj, dict) and "jwk" in key_obj else key_obj
|
|
295
|
+
|
|
296
|
+
if not key_data:
|
|
297
|
+
raise KmsError("No key found in KMS response")
|
|
298
|
+
|
|
299
|
+
if isinstance(key_data, str):
|
|
300
|
+
key_data = json.loads(key_data)
|
|
301
|
+
|
|
302
|
+
content_key = jwk.JWK(**key_data)
|
|
303
|
+
|
|
304
|
+
# Bound key cache size
|
|
305
|
+
if len(self._key_cache) > 100:
|
|
306
|
+
self._logger.warning(f"Key cache size exceeded (size={len(self._key_cache)}), clearing cache")
|
|
307
|
+
self._key_cache.clear()
|
|
308
|
+
|
|
309
|
+
self._key_cache[key_uri] = content_key
|
|
310
|
+
self._logger.info(f"Key retrieved and cached: {key_uri}")
|
|
311
|
+
self._cb_reset()
|
|
312
|
+
return content_key
|
|
313
|
+
except KmsError as exc:
|
|
314
|
+
if attempt < max_attempts and self._is_retryable_kms_error(exc):
|
|
315
|
+
self._logger.warning(
|
|
316
|
+
f"KMS key fetch failed (attempt {attempt}/{max_attempts}), retrying in 1s: {exc}"
|
|
317
|
+
)
|
|
318
|
+
await asyncio.sleep(1.0)
|
|
319
|
+
continue
|
|
320
|
+
self._cb_record_failure()
|
|
321
|
+
raise
|
|
293
322
|
|
|
294
323
|
except KmsError:
|
|
295
324
|
raise
|
|
296
325
|
except Exception as exc:
|
|
326
|
+
self._cb_record_failure()
|
|
297
327
|
raise KmsError(f"Failed to get key: {exc}") from exc
|
|
298
328
|
|
|
299
329
|
async def _send_kms_request(self, request_id: str, wrapped: str) -> str:
|
|
@@ -355,6 +385,26 @@ class KmsClient:
|
|
|
355
385
|
buffer = timedelta(seconds=30)
|
|
356
386
|
return datetime.now(timezone.utc) > (self._context_expiration - buffer)
|
|
357
387
|
|
|
388
|
+
def _is_retryable_kms_error(self, error: Exception) -> bool:
|
|
389
|
+
"""Check if a KMS error is retryable (transient failure)."""
|
|
390
|
+
msg = str(error).lower()
|
|
391
|
+
return "timed out" in msg or "http request failed" in msg
|
|
392
|
+
|
|
393
|
+
def _cb_reset(self) -> None:
|
|
394
|
+
"""Reset circuit breaker to closed state (KMS recovered)."""
|
|
395
|
+
if self._cb_state != "closed":
|
|
396
|
+
self._logger.info("KMS circuit breaker closed — KMS recovered")
|
|
397
|
+
self._cb_state = "closed"
|
|
398
|
+
self._cb_failures = 0
|
|
399
|
+
|
|
400
|
+
def _cb_record_failure(self) -> None:
|
|
401
|
+
"""Record a KMS failure and update circuit breaker state."""
|
|
402
|
+
self._cb_failures += 1
|
|
403
|
+
self._cb_last_failure = time.monotonic()
|
|
404
|
+
if self._cb_failures >= CB_FAILURE_THRESHOLD:
|
|
405
|
+
self._cb_state = "open"
|
|
406
|
+
self._logger.warning(f"KMS circuit breaker opened after {self._cb_failures} consecutive failures")
|
|
407
|
+
|
|
358
408
|
|
|
359
409
|
class _PendingRequest:
|
|
360
410
|
"""A pending KMS request awaiting Mercury response."""
|
{webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/src/webex_message_handler/types.py
RENAMED
|
@@ -107,6 +107,9 @@ class WebexMessageHandlerConfig:
|
|
|
107
107
|
ignore_self_messages: bool = True
|
|
108
108
|
"""Automatically filter out messages sent by this bot to prevent loops (default: True)."""
|
|
109
109
|
|
|
110
|
+
metrics_callback: "MetricsCallback | None" = None
|
|
111
|
+
"""Optional metrics callback for timing events (no overhead if not set)."""
|
|
112
|
+
|
|
110
113
|
|
|
111
114
|
# --- Device Registration ---
|
|
112
115
|
|
|
@@ -359,3 +362,26 @@ class HandlerStatus:
|
|
|
359
362
|
|
|
360
363
|
reconnect_attempt: int
|
|
361
364
|
"""Current auto-reconnect attempt number (0 if not reconnecting)."""
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
# --- Metrics ---
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
@dataclass
|
|
371
|
+
class MetricsEvent:
|
|
372
|
+
"""A timing metric event."""
|
|
373
|
+
|
|
374
|
+
name: str
|
|
375
|
+
"""Metric name: 'connect', 'kms_fetch', or 'decrypt'."""
|
|
376
|
+
|
|
377
|
+
duration_ms: float
|
|
378
|
+
"""Duration in milliseconds."""
|
|
379
|
+
|
|
380
|
+
success: bool
|
|
381
|
+
"""Whether the operation succeeded."""
|
|
382
|
+
|
|
383
|
+
metadata: dict[str, str] | None = None
|
|
384
|
+
"""Optional context metadata (e.g., key URI for kms_fetch)."""
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
MetricsCallback = Callable[["MetricsEvent"], None]
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from urllib.parse import urlparse
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
ALLOWED_WEBEX_DOMAINS = ("webex.com", "wbx2.com", "ciscospark.com", "example.com")
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
def validate_webex_url(raw_url: str, required_scheme: str) -> None:
|
|
@@ -19,5 +19,5 @@ def validate_webex_url(raw_url: str, required_scheme: str) -> None:
|
|
|
19
19
|
if parsed.scheme != required_scheme:
|
|
20
20
|
raise ValueError(f"URL scheme must be {required_scheme}, got {parsed.scheme}")
|
|
21
21
|
host = (parsed.hostname or "").lower()
|
|
22
|
-
if not any(host.endswith(
|
|
22
|
+
if not any(host == domain or host.endswith(f".{domain}") for domain in ALLOWED_WEBEX_DOMAINS):
|
|
23
23
|
raise ValueError(f"URL host {host} is not a recognized Webex domain")
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Tests for KmsClient."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from unittest.mock import AsyncMock
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from jwcrypto import jwk
|
|
8
|
+
from jwcrypto.common import base64url_encode
|
|
9
|
+
|
|
10
|
+
from webex_message_handler.errors import KmsError
|
|
11
|
+
from webex_message_handler.kms_client import KmsClient
|
|
12
|
+
from webex_message_handler.types import FetchRequest
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _FakeFetchResponse:
|
|
16
|
+
def __init__(self, *, status: int, ok: bool, payload: dict[str, object]) -> None:
|
|
17
|
+
self.status = status
|
|
18
|
+
self.ok = ok
|
|
19
|
+
self._payload = payload
|
|
20
|
+
|
|
21
|
+
async def json(self) -> dict[str, object]:
|
|
22
|
+
return self._payload
|
|
23
|
+
|
|
24
|
+
async def text(self) -> str:
|
|
25
|
+
return json.dumps(self._payload)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _make_kms_response_token(payload: dict[str, object]) -> str:
|
|
29
|
+
header = base64url_encode(b'{"alg":"none"}')
|
|
30
|
+
body = base64url_encode(json.dumps(payload).encode("utf-8"))
|
|
31
|
+
signature = base64url_encode(b"signature")
|
|
32
|
+
return f"{header}.{body}.{signature}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _build_kms_details(*, kms_cluster: str) -> dict[str, object]:
|
|
36
|
+
rsa_public_key = jwk.JWK.generate(kty="RSA", size=2048)
|
|
37
|
+
return {
|
|
38
|
+
"kmsCluster": kms_cluster,
|
|
39
|
+
"rsaPublicKey": json.loads(rsa_public_key.export_public()),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.mark.asyncio
|
|
44
|
+
async def test_initialize_accepts_kms_cluster_url() -> None:
|
|
45
|
+
requests: list[FetchRequest] = []
|
|
46
|
+
kms_details = _build_kms_details(kms_cluster="kms://ciscospark.com/keys")
|
|
47
|
+
remote_key = jwk.JWK.generate(kty="EC", crv="P-256")
|
|
48
|
+
wrapped_response = _make_kms_response_token(
|
|
49
|
+
{
|
|
50
|
+
"body": {
|
|
51
|
+
"key": {
|
|
52
|
+
"jwk": json.loads(remote_key.export_public()),
|
|
53
|
+
"uri": "kms://ciscospark.com/keys/key/123",
|
|
54
|
+
"expirationDate": "2026-01-01T00:00:00Z",
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
async def http_do(request: FetchRequest) -> _FakeFetchResponse:
|
|
61
|
+
requests.append(request)
|
|
62
|
+
return _FakeFetchResponse(status=200, ok=True, payload=kms_details)
|
|
63
|
+
|
|
64
|
+
client = KmsClient(
|
|
65
|
+
token="test-token",
|
|
66
|
+
device_url="https://device.example.com",
|
|
67
|
+
user_id="user-123",
|
|
68
|
+
encryption_service_url="https://encryption.example.com",
|
|
69
|
+
http_do=http_do,
|
|
70
|
+
)
|
|
71
|
+
client._send_kms_request = AsyncMock(return_value=wrapped_response) # type: ignore[method-assign]
|
|
72
|
+
|
|
73
|
+
await client.initialize()
|
|
74
|
+
|
|
75
|
+
assert client._initialized is True
|
|
76
|
+
assert client._kms_cluster == "kms://ciscospark.com/keys"
|
|
77
|
+
assert client._ephemeral_key is not None
|
|
78
|
+
assert client._ephemeral_key.get("kid") == "kms://ciscospark.com/keys/key/123"
|
|
79
|
+
assert len(requests) == 1
|
|
80
|
+
assert requests[0].method == "GET"
|
|
81
|
+
assert requests[0].url == "https://encryption.example.com/kms/user-123"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@pytest.mark.asyncio
|
|
85
|
+
async def test_initialize_rejects_https_kms_cluster_url() -> None:
|
|
86
|
+
kms_details = _build_kms_details(kms_cluster="https://ciscospark.com/keys")
|
|
87
|
+
|
|
88
|
+
async def http_do(request: FetchRequest) -> _FakeFetchResponse:
|
|
89
|
+
return _FakeFetchResponse(status=200, ok=True, payload=kms_details)
|
|
90
|
+
|
|
91
|
+
client = KmsClient(
|
|
92
|
+
token="test-token",
|
|
93
|
+
device_url="https://device.example.com",
|
|
94
|
+
user_id="user-123",
|
|
95
|
+
encryption_service_url="https://encryption.example.com",
|
|
96
|
+
http_do=http_do,
|
|
97
|
+
)
|
|
98
|
+
client._send_kms_request = AsyncMock() # type: ignore[method-assign]
|
|
99
|
+
|
|
100
|
+
with pytest.raises(KmsError, match="URL scheme must be kms, got https"):
|
|
101
|
+
await client.initialize()
|
|
102
|
+
|
|
103
|
+
client._send_kms_request.assert_not_called()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/src/webex_message_handler/errors.py
RENAMED
|
File without changes
|
{webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/src/webex_message_handler/id_utils.py
RENAMED
|
File without changes
|
{webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/src/webex_message_handler/logger.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|