devora-python 0.1.0__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.
devora_sdk/sdk.py ADDED
@@ -0,0 +1,390 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import inspect
5
+ import os
6
+ import re
7
+ import time
8
+ from typing import Any, Callable, Optional
9
+
10
+ from .constants import (
11
+ DEFAULT_API_URL,
12
+ DEFAULT_TIMESTAMP_TOLERANCE_SECONDS,
13
+ DEVORA_ENDPOINTS,
14
+ ENDPOINT_METHODS,
15
+ PROTECTED_ENDPOINTS,
16
+ SDK_VERSION,
17
+ BROWSER_RESUME_CODE_ENDPOINT,
18
+ TAB_REF_PATTERN,
19
+ )
20
+ from .hmac import sha256_hex, sign_request, signature_matches
21
+ from .models import DevoraRequest, SDKRoute, SDKStats, ValidationResult
22
+ from .policy import ScopeConfig, ScopeConfigFetcher
23
+ from .security import is_valid_timestamp_tolerance, validate_timestamp, validate_timestamp_tolerance
24
+ from .signing import (
25
+ CUSTOMER_TO_DEVORA,
26
+ DEVORA_TO_CUSTOMER,
27
+ build_canonical_string,
28
+ has_identity_content_encoding,
29
+ is_valid_signed_path,
30
+ is_valid_signed_query,
31
+ matches,
32
+ parse_signature_headers,
33
+ replay_expires_at_ms,
34
+ replay_namespace,
35
+ )
36
+ from .transport import ControlPlaneError, control_plane_request
37
+ from .replay import InMemoryReplayStore, ReplayStore
38
+
39
+
40
+ def resolve_environment(explicit: Optional[str] = None) -> str:
41
+ """Explicit option first, then DEVORA_ENV / NODE_ENV as hints, else production."""
42
+ raw = (explicit or os.environ.get("DEVORA_ENV") or os.environ.get("NODE_ENV") or "production")
43
+ raw = str(raw).strip().lower()
44
+ return raw if raw in ("development", "test") else "production"
45
+
46
+
47
+ class DevoraBackendSDK:
48
+ def __init__(
49
+ self,
50
+ api_key: str,
51
+ secret_key: str,
52
+ org_id: str,
53
+ timestamp_tolerance: int = DEFAULT_TIMESTAMP_TOLERANCE_SECONDS,
54
+ collect_stats: bool = False,
55
+ debug: bool = False,
56
+ api_url: Optional[str] = None,
57
+ replay_store: Optional[ReplayStore] = None,
58
+ environment: Optional[str] = None,
59
+ prefetch_scope_config: bool = False,
60
+ **_ignored_options: Any,
61
+ ) -> None:
62
+ # Unknown preferences are intentionally discarded; capture is owned by Devora.
63
+ if not matches("key_id", api_key):
64
+ raise ValueError("Devora SDK: api_key must be a server key identifier (pk_server_live_*)")
65
+ if not _valid_secret_key(secret_key):
66
+ raise ValueError("Devora SDK: invalid server secret key")
67
+ if not org_id.strip():
68
+ raise ValueError("Devora SDK: org_id is required")
69
+ validate_timestamp_tolerance(timestamp_tolerance)
70
+
71
+ self.api_key = api_key
72
+ self._secret_key = secret_key
73
+ self.org_id = org_id
74
+ self.api_url = _resolve_api_url(api_url)
75
+ self.timestamp_tolerance = timestamp_tolerance
76
+ self.collect_stats = collect_stats
77
+ self.debug = debug
78
+ self.stats = SDKStats()
79
+ self._routes: list[SDKRoute] = []
80
+ # Fail closed: anything that is not explicitly a development/test runtime
81
+ # is production, and production must share replay protection across
82
+ # processes. Explicit option first, then DEVORA_ENV / NODE_ENV as hints.
83
+ self.environment = resolve_environment(environment)
84
+ if self.environment == "production" and replay_store is None:
85
+ raise ValueError(
86
+ "Devora SDK: replay_store is required in production. Pass a persistent "
87
+ "ReplayStore, or environment='development' (InMemoryReplayStore) for local "
88
+ "development only."
89
+ )
90
+ self._replay_store = replay_store or InMemoryReplayStore()
91
+ self._scope_config = ScopeConfigFetcher(
92
+ api_key=api_key,
93
+ api_url=self.api_url,
94
+ prefetch=prefetch_scope_config,
95
+ sign_request=lambda: sign_request(
96
+ secret_key=self._secret_key,
97
+ direction=CUSTOMER_TO_DEVORA,
98
+ key_id=self.api_key,
99
+ org_id=self.org_id,
100
+ method="GET",
101
+ path="/api/sdk/scope-config",
102
+ ),
103
+ )
104
+ self._register_built_ins()
105
+
106
+ @property
107
+ def config(self) -> dict[str, Any]:
108
+ """Redacted configuration view. The secret key is never exposed."""
109
+ return {
110
+ "api_key": self.api_key,
111
+ "secret_key": "[REDACTED]",
112
+ "org_id": self.org_id,
113
+ "api_url": self.api_url,
114
+ "timestamp_tolerance": self.timestamp_tolerance,
115
+ "collect_stats": self.collect_stats,
116
+ "debug": self.debug,
117
+ }
118
+
119
+ def register(
120
+ self,
121
+ path: str,
122
+ handler: Optional[Callable[[DevoraRequest], Any]] = None,
123
+ method: Optional[str] = None,
124
+ ) -> Any:
125
+ def add_route(route_handler: Callable[[DevoraRequest], Any]) -> SDKRoute:
126
+ if path in PROTECTED_ENDPOINTS:
127
+ raise ValueError(f"Cannot override protected endpoint: {path}")
128
+ if any(route.path == path for route in self._routes):
129
+ raise ValueError(f"Endpoint already registered: {path}")
130
+ route_method = (ENDPOINT_METHODS.get(path) or method or "GET").upper()
131
+ route = SDKRoute(path=path, method=route_method, handler=route_handler, is_built_in=False)
132
+ self._routes.append(route)
133
+ return route
134
+
135
+ if handler is None:
136
+ return add_route
137
+ return add_route(handler)
138
+
139
+ def get_routes(self) -> list[SDKRoute]:
140
+ return list(self._routes)
141
+
142
+ def get_stats(self) -> dict[str, Any]:
143
+ return self.stats.to_dict()
144
+
145
+ def verify_request(
146
+ self,
147
+ method: str,
148
+ path: str,
149
+ query: str,
150
+ body: bytes,
151
+ headers: Any,
152
+ timestamp_tolerance: Optional[int] = None,
153
+ ) -> ValidationResult:
154
+ """Verify a signed request from Devora (signature v3) over its exact bytes.
155
+
156
+ ``path`` is relative to the SDK mount and still percent-encoded; ``query``
157
+ is everything after the first ``?``; ``body`` is the raw bytes; ``headers``
158
+ is a mapping or a list of ``(name, value)`` pairs (duplicates are
159
+ rejected). Steps before the replay store never touch it.
160
+ """
161
+ tolerance = self.timestamp_tolerance if timestamp_tolerance is None else timestamp_tolerance
162
+ if not is_valid_timestamp_tolerance(tolerance):
163
+ return ValidationResult(valid=False, error="Invalid timestamp tolerance", error_code="TIMESTAMP_EXPIRED")
164
+
165
+ parsed, error_code, error_message = parse_signature_headers(headers)
166
+ if parsed is None:
167
+ return ValidationResult(valid=False, error=error_message, error_code=error_code)
168
+ if parsed["key_id"] != self.api_key or parsed["org_id"] != self.org_id:
169
+ return ValidationResult(
170
+ valid=False,
171
+ error="Request key or organization does not match SDK configuration",
172
+ error_code="ORG_MISMATCH",
173
+ )
174
+ sent_at = int(parsed["sent_at"])
175
+ timestamp_ok, timestamp_error = validate_timestamp(sent_at, tolerance)
176
+ if not timestamp_ok:
177
+ return ValidationResult(valid=False, error=timestamp_error, error_code="TIMESTAMP_EXPIRED")
178
+ if not matches("method", method) or not is_valid_signed_path(path) or not is_valid_signed_query(query):
179
+ return ValidationResult(valid=False, error="Invalid request target", error_code="INVALID_REQUEST_TARGET")
180
+ if not has_identity_content_encoding(headers):
181
+ return ValidationResult(
182
+ valid=False, error="Content-Encoding is not supported", error_code="UNSUPPORTED_CONTENT_ENCODING"
183
+ )
184
+ if not isinstance(body, (bytes, bytearray)):
185
+ return ValidationResult(valid=False, error="Raw body bytes are required", error_code="INVALID_BODY")
186
+
187
+ canonical = build_canonical_string(
188
+ direction=DEVORA_TO_CUSTOMER,
189
+ key_id=parsed["key_id"],
190
+ org_id=parsed["org_id"],
191
+ sent_at=parsed["sent_at"],
192
+ request_id=parsed["request_id"],
193
+ method=method,
194
+ path=path,
195
+ query=query,
196
+ body_sha256=sha256_hex(bytes(body)),
197
+ )
198
+ if not signature_matches(self._secret_key, canonical, parsed["signature"]):
199
+ return ValidationResult(valid=False, error="Invalid HMAC signature", error_code="INVALID_SIGNATURE")
200
+
201
+ try:
202
+ fresh = self._replay_store.consume(
203
+ replay_namespace(DEVORA_TO_CUSTOMER, parsed["key_id"]),
204
+ parsed["request_id"],
205
+ replay_expires_at_ms(sent_at, int(time.time()), tolerance),
206
+ )
207
+ except Exception:
208
+ return ValidationResult(
209
+ valid=False, error="Replay protection is unavailable", error_code="REPLAY_STORE_UNAVAILABLE"
210
+ )
211
+ if not isinstance(fresh, bool):
212
+ if inspect.iscoroutine(fresh):
213
+ fresh.close()
214
+ return ValidationResult(
215
+ valid=False, error="Replay store must return a boolean decision", error_code="REPLAY_STORE_UNAVAILABLE"
216
+ )
217
+ if not fresh:
218
+ return ValidationResult(valid=False, error="Request was already processed", error_code="REPLAYED_REQUEST")
219
+ return ValidationResult(
220
+ valid=True,
221
+ org_id=parsed["org_id"],
222
+ key_id=parsed["key_id"],
223
+ timestamp=sent_at,
224
+ request_id=parsed["request_id"],
225
+ )
226
+
227
+ def get_scope_config(self) -> Optional[ScopeConfig]:
228
+ return self._scope_config.get_config()
229
+
230
+ def get_cached_scope_config(self) -> Optional[ScopeConfig]:
231
+ return self._scope_config.get_cached_config()
232
+
233
+ def refresh_scope_config(self) -> Optional[ScopeConfig]:
234
+ return self._scope_config.refresh()
235
+
236
+ def _signed_devora_post(self, path: str, payload: dict[str, Any]) -> tuple[int, Optional[dict[str, Any]]]:
237
+ """POST a signed JSON body to a Devora SDK endpoint (customer-to-devora)."""
238
+ body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
239
+ headers = sign_request(
240
+ secret_key=self._secret_key,
241
+ direction=CUSTOMER_TO_DEVORA,
242
+ key_id=self.api_key,
243
+ org_id=self.org_id,
244
+ method="POST",
245
+ path=path,
246
+ body=body,
247
+ )
248
+ status, json_body, _ = control_plane_request(f"{self.api_url}{path}", "POST", headers, body)
249
+ return status, json_body
250
+
251
+ def get_session_status(self, session_id: str) -> Optional[dict[str, Any]]:
252
+ """Check whether a Devora session is still live (server-side liveness check).
253
+
254
+ Returns the status payload (including a ``valid`` flag), or ``None`` when Devora is
255
+ unreachable so callers can choose fail-open or fail-closed behavior.
256
+ """
257
+ if not isinstance(session_id, str) or not session_id or len(session_id) > 128:
258
+ return None
259
+ try:
260
+ status, payload = self._signed_devora_post("/api/sdk/session-status", {"sessionId": session_id})
261
+ except (ControlPlaneError, ValueError):
262
+ return None
263
+ if status == 404:
264
+ return {"valid": False, "error": "SESSION_NOT_FOUND"}
265
+ if not (200 <= status < 300) or not payload or payload.get("success") is not True:
266
+ return None
267
+ data = payload.get("data")
268
+ if isinstance(data, dict) and isinstance(data.get("valid"), bool):
269
+ return data
270
+ return None
271
+
272
+ def create_browser_resume_code(
273
+ self, session_id: str, tab_ref: str, origin: str
274
+ ) -> Optional[dict[str, Any]]:
275
+ """Mint a one-time browser resume code for an active session.
276
+
277
+ Signed customer-to-devora. Returns ``{"code", "expiresAt"}``; ``{"error": ...}``
278
+ when Devora rejected the request; ``None`` when Devora is unreachable.
279
+ """
280
+ if not isinstance(session_id, str) or not session_id or len(session_id) > 128:
281
+ return {"error": "INVALID_SESSION"}
282
+ if not isinstance(tab_ref, str) or not re.fullmatch(TAB_REF_PATTERN, tab_ref):
283
+ return {"error": "INVALID_TAB_REF"}
284
+ try:
285
+ status, payload = self._signed_devora_post(
286
+ BROWSER_RESUME_CODE_ENDPOINT, {"sessionId": session_id, "tabRef": tab_ref, "origin": origin}
287
+ )
288
+ except (ControlPlaneError, ValueError):
289
+ return None
290
+ data = payload.get("data") if payload else None
291
+ if 200 <= status < 300 and payload and payload.get("success") is True and isinstance(data, dict) and isinstance(data.get("code"), str):
292
+ return data
293
+ if status >= 500 or status == 429:
294
+ return None
295
+ error_code = payload.get("error") if payload else None
296
+ return {"error": error_code if isinstance(error_code, str) else "RESUME_REJECTED", "status": status}
297
+
298
+ def destroy(self) -> None:
299
+ self._scope_config.stop()
300
+
301
+ def record_request(self, endpoint: str, outcome: str) -> None:
302
+ self.stats.total_requests += 1
303
+ if self.collect_stats:
304
+ self.stats.requests_by_endpoint[endpoint] = self.stats.requests_by_endpoint.get(endpoint, 0) + 1
305
+ if outcome == "success":
306
+ self.stats.successful_requests += 1
307
+ else:
308
+ self.stats.failed_requests += 1
309
+ if outcome == "security_error":
310
+ self.stats.security_errors += 1
311
+
312
+ def _register_built_ins(self) -> None:
313
+ def test_handler(req: DevoraRequest) -> dict[str, Any]:
314
+ return {
315
+ "status": "success",
316
+ "message": "Devora SDK connection test successful",
317
+ "timestamp": _now_ms(),
318
+ "orgId": req.org_id,
319
+ "keyId": req.key_id,
320
+ "sdk": {"name": "devora-python", "version": SDK_VERSION, "type": "backend", "runtime": "python"},
321
+ "security": {"hmacValidated": True, "timestampValid": True},
322
+ }
323
+
324
+ def health_handler(_: DevoraRequest) -> dict[str, Any]:
325
+ return {
326
+ "status": "healthy",
327
+ "sdk": {"name": "devora-python", "version": SDK_VERSION, "type": "backend", "runtime": "python"},
328
+ "stats": self.get_stats() if self.collect_stats else None,
329
+ "endpoints": {route.path: route.method for route in self._routes},
330
+ }
331
+
332
+ self._routes.append(SDKRoute(DEVORA_ENDPOINTS.TEST, "GET", test_handler, True))
333
+ self._routes.append(SDKRoute(DEVORA_ENDPOINTS.HEALTH, "GET", health_handler, True))
334
+
335
+
336
+ def devora_sdk(
337
+ api_key: str,
338
+ secret_key: str,
339
+ org_id: str,
340
+ timestamp_tolerance: int = DEFAULT_TIMESTAMP_TOLERANCE_SECONDS,
341
+ collect_stats: bool = False,
342
+ debug: bool = False,
343
+ api_url: Optional[str] = None,
344
+ replay_store: Optional[ReplayStore] = None,
345
+ environment: Optional[str] = None,
346
+ prefetch_scope_config: bool = False,
347
+ **_ignored_options: Any,
348
+ ) -> DevoraBackendSDK:
349
+ return DevoraBackendSDK(
350
+ api_key=api_key,
351
+ secret_key=secret_key,
352
+ org_id=org_id,
353
+ timestamp_tolerance=timestamp_tolerance,
354
+ collect_stats=collect_stats,
355
+ debug=debug,
356
+ api_url=api_url,
357
+ replay_store=replay_store,
358
+ environment=environment,
359
+ prefetch_scope_config=prefetch_scope_config,
360
+ )
361
+
362
+
363
+ def _valid_secret_key(secret_key: str) -> bool:
364
+ return matches("secret_key", secret_key)
365
+
366
+
367
+ def _resolve_api_url(override: Optional[str]) -> str:
368
+ """Validate an API origin override (advanced; e.g. self-hosted deployments).
369
+
370
+ Only https origins are accepted (http for localhost/127.0.0.1 development).
371
+ """
372
+ if not override:
373
+ return DEFAULT_API_URL
374
+ from urllib.parse import urlparse
375
+
376
+ parsed = urlparse(override.strip())
377
+ is_localhost = parsed.hostname in ("localhost", "127.0.0.1")
378
+ if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
379
+ raise ValueError("Devora SDK: api_url must use https (http is allowed for localhost only)")
380
+ if parsed.username or parsed.password or (parsed.path and parsed.path != "/") or parsed.query or parsed.fragment:
381
+ raise ValueError("Devora SDK: api_url must be an origin without path, query, or credentials")
382
+ if not parsed.netloc:
383
+ raise ValueError(f"Devora SDK: api_url is not a valid URL: {override}")
384
+ return f"{parsed.scheme}://{parsed.netloc}"
385
+
386
+
387
+ def _now_ms() -> int:
388
+ import time
389
+
390
+ return int(time.time() * 1000)
devora_sdk/security.py ADDED
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Optional
5
+
6
+ from .constants import DEFAULT_TIMESTAMP_TOLERANCE_SECONDS
7
+
8
+
9
+ def is_valid_timestamp_tolerance(value: object) -> bool:
10
+ """A tolerance is a whole, non-negative number of seconds (0 = current second only)."""
11
+ return isinstance(value, int) and not isinstance(value, bool) and value >= 0
12
+
13
+
14
+ def validate_timestamp_tolerance(value: object, name: str = "timestamp_tolerance") -> None:
15
+ """Raise for configuration values that :func:`is_valid_timestamp_tolerance` rejects.
16
+
17
+ ``None`` means "use the default" and is accepted. NaN, infinities, negatives,
18
+ floats and bools are rejected: they would make every drift comparison pass.
19
+ """
20
+ if value is not None and not is_valid_timestamp_tolerance(value):
21
+ raise ValueError(f"Devora SDK: {name} must be a non-negative integer number of seconds")
22
+
23
+
24
+ def validate_timestamp(
25
+ request_timestamp: int, tolerance_seconds: int = DEFAULT_TIMESTAMP_TOLERANCE_SECONDS
26
+ ) -> tuple[bool, Optional[str]]:
27
+ if not is_valid_timestamp_tolerance(tolerance_seconds):
28
+ return False, "Invalid timestamp tolerance"
29
+ if not isinstance(request_timestamp, int) or request_timestamp <= 0:
30
+ return False, "Invalid timestamp"
31
+ drift = abs(int(time.time()) - request_timestamp)
32
+ if drift > tolerance_seconds:
33
+ return (
34
+ False,
35
+ f"Request timestamp outside tolerance window (drift: {drift}s, max: {tolerance_seconds}s)",
36
+ )
37
+ return True, None
devora_sdk/signing.py ADDED
@@ -0,0 +1,240 @@
1
+ """Devora request signing, version 3. Mirrors @devorash/core security/signing.ts.
2
+
3
+ The signature covers the exact bytes on the wire: raw path, raw query and a
4
+ SHA-256 of the raw body, plus the direction the request travels in. Every
5
+ header has one accepted spelling. Conformance vectors:
6
+ packages/sdks/test/signing-v3-vectors.json.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import math
12
+ import re
13
+ from typing import Any, Iterable, Mapping, Optional, Sequence, Union
14
+ from urllib.parse import parse_qsl, unquote
15
+
16
+ from .constants import SECURITY_HEADERS
17
+
18
+ VERSION = "3"
19
+ ALGORITHM = "DEVORA-HMAC-SHA256"
20
+ DEVORA_TO_CUSTOMER = "devora-to-customer"
21
+ CUSTOMER_TO_DEVORA = "customer-to-devora"
22
+ EMPTY_BODY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
23
+
24
+ #: Exact header grammars. A value must match in full; nothing is trimmed.
25
+ PATTERNS = {
26
+ "signature_version": re.compile(r"3"),
27
+ "key_id": re.compile(r"pk_server_live_[A-Za-z0-9_-]{32}"),
28
+ "org_id": re.compile(r"[A-Za-z0-9_-]{1,128}"),
29
+ "sent_at": re.compile(r"[1-9][0-9]{9}"),
30
+ "request_id": re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"),
31
+ "signature": re.compile(r"[0-9a-f]{64}"),
32
+ "method": re.compile(r"[A-Z]{3,7}"),
33
+ "body_sha256": re.compile(r"[0-9a-f]{64}"),
34
+ "secret_key": re.compile(r"sk_server_live_[A-Za-z0-9_-]{64}"),
35
+ }
36
+
37
+ HeaderInput = Union[Mapping[str, Any], Sequence[tuple[Any, Any]]]
38
+
39
+ _UNRESERVED = frozenset(b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~")
40
+
41
+
42
+ def matches(name: str, value: Any) -> bool:
43
+ """Full-string ASCII match; ``str.isdigit``-style Unicode digits never pass."""
44
+ return isinstance(value, str) and value.isascii() and PATTERNS[name].fullmatch(value) is not None
45
+
46
+
47
+ def strict_encode(value: str) -> str:
48
+ """UTF-8 percent-encode every byte outside ``A-Z a-z 0-9 - _ . ~`` (uppercase hex)."""
49
+ return "".join(chr(byte) if byte in _UNRESERVED else f"%{byte:02X}" for byte in value.encode("utf-8"))
50
+
51
+
52
+ def encode_path_param(value: str) -> str:
53
+ """Encode one path parameter; ``""``, ``.`` and ``..`` cannot be sent as signed."""
54
+ if value in ("", ".", ".."):
55
+ raise ValueError("Devora signing: unroutable path parameter")
56
+ return strict_encode(value)
57
+
58
+
59
+ def build_strict_query(pairs: Iterable[tuple[str, str]]) -> str:
60
+ """Strict-encoded ``key=value`` pairs in the given order (the order is signed)."""
61
+ return "&".join(f"{strict_encode(key)}={strict_encode(value)}" for key, value in pairs)
62
+
63
+
64
+ _STRICT_ASCII = re.compile(r"[\x21-\x7E]*")
65
+ _BAD_ESCAPE = re.compile(r"%(?![0-9A-F]{2})")
66
+
67
+
68
+ def _is_strict_ascii(value: str) -> bool:
69
+ return _STRICT_ASCII.fullmatch(value) is not None and not _BAD_ESCAPE.search(value)
70
+
71
+
72
+ def is_valid_signed_path(path: str) -> bool:
73
+ """Absolute, printable ASCII, uppercase escapes, no empty or dot segments."""
74
+ if not isinstance(path, str) or not path.startswith("/") or len(path) > 8192 or not _is_strict_ascii(path) or any(char in path for char in "?#\\"):
75
+ return False
76
+ if path == "/":
77
+ return True
78
+ try:
79
+ unquote(path, errors="strict")
80
+ except UnicodeDecodeError:
81
+ return False
82
+ return all(segment.replace("%2E", ".") not in ("", ".", "..") for segment in path[1:].split("/"))
83
+
84
+
85
+ def is_valid_signed_query(query: str) -> bool:
86
+ if not isinstance(query, str) or "#" in query or len(query) > 8192 or not _is_strict_ascii(query):
87
+ return False
88
+ try:
89
+ unquote(query, errors="strict")
90
+ return True
91
+ except UnicodeDecodeError:
92
+ return False
93
+
94
+
95
+ def build_canonical_string(
96
+ *,
97
+ direction: str,
98
+ key_id: str,
99
+ org_id: str,
100
+ sent_at: str,
101
+ request_id: str,
102
+ method: str,
103
+ path: str,
104
+ query: str,
105
+ body_sha256: str,
106
+ ) -> str:
107
+ """Build the canonical string. Raises ValueError if any field is outside its grammar."""
108
+ checks = [
109
+ (direction in (DEVORA_TO_CUSTOMER, CUSTOMER_TO_DEVORA), "direction"),
110
+ (matches("key_id", key_id), "key id"),
111
+ (matches("org_id", org_id), "org id"),
112
+ (matches("sent_at", sent_at), "sent-at"),
113
+ (matches("request_id", request_id), "request id"),
114
+ (matches("method", method), "method"),
115
+ (is_valid_signed_path(path), "path"),
116
+ (is_valid_signed_query(query), "query"),
117
+ (matches("body_sha256", body_sha256), "body digest"),
118
+ ]
119
+ for ok, name in checks:
120
+ if not ok:
121
+ raise ValueError(f"Devora signing: invalid {name}")
122
+ return "\n".join(
123
+ [ALGORITHM, VERSION, direction, key_id, org_id, sent_at, request_id, method, path, query, body_sha256]
124
+ )
125
+
126
+
127
+ def _header_items(headers: HeaderInput) -> Iterable[tuple[str, Any]]:
128
+ if isinstance(headers, Mapping):
129
+ return headers.items()
130
+ return headers
131
+
132
+
133
+ def get_single_header(headers: HeaderInput, name: str) -> Union[str, None, bool]:
134
+ """The single value of a header; ``None`` if absent, ``False`` if repeated.
135
+
136
+ Accepts a mapping or a list of ``(name, value)`` pairs (so frameworks that
137
+ keep duplicates can pass them). Byte values are decoded as latin-1.
138
+ """
139
+ values: list[str] = []
140
+ for key, value in _header_items(headers):
141
+ key = key.decode("latin-1") if isinstance(key, (bytes, bytearray)) else str(key)
142
+ if key.lower() != name or value is None:
143
+ continue
144
+ for item in value if isinstance(value, (list, tuple)) else [value]:
145
+ values.append(item.decode("latin-1") if isinstance(item, (bytes, bytearray)) else str(item))
146
+ if len(values) > 1:
147
+ return False
148
+ return values[0] if values else None
149
+
150
+
151
+ def parse_signature_headers(headers: HeaderInput) -> tuple[Optional[dict[str, str]], Optional[str], Optional[str]]:
152
+ """Return ``(headers, error_code, error)``. Never first-wins, never trims."""
153
+ version = get_single_header(headers, SECURITY_HEADERS.SIGNATURE_VERSION)
154
+ values = {
155
+ "key_id": get_single_header(headers, SECURITY_HEADERS.KEY_ID),
156
+ "org_id": get_single_header(headers, SECURITY_HEADERS.ORG_ID),
157
+ "sent_at": get_single_header(headers, SECURITY_HEADERS.SENT_AT),
158
+ "request_id": get_single_header(headers, SECURITY_HEADERS.REQUEST_ID),
159
+ "signature": get_single_header(headers, SECURITY_HEADERS.SIGNATURE),
160
+ }
161
+ if isinstance(version, str) and version != VERSION and version.isascii() and version.isdigit():
162
+ return None, "UNSUPPORTED_SIGNATURE_VERSION", "Unsupported signature version"
163
+ if not matches("signature_version", version) or not all(matches(name, value) for name, value in values.items()):
164
+ return None, "INVALID_SIGNATURE_HEADERS", "Missing, duplicate or malformed signature headers"
165
+ return values, None, None # type: ignore[return-value]
166
+
167
+
168
+ def has_identity_content_encoding(headers: HeaderInput) -> bool:
169
+ value = get_single_header(headers, "content-encoding")
170
+ return value is None or value == "identity"
171
+
172
+
173
+ def replay_namespace(direction: str, key_id: str) -> str:
174
+ return f"v{VERSION}:{direction}:{key_id}"
175
+
176
+
177
+ def replay_expires_at_ms(sent_at: int, now_seconds: int, tolerance_seconds: int) -> int:
178
+ """Covers the floor second of the timestamp check plus one second of jitter."""
179
+ return (max(now_seconds, sent_at) + tolerance_seconds + 2) * 1000
180
+
181
+
182
+ def parse_verified_query(query: str) -> dict[str, Union[str, list[str]]]:
183
+ """Parse a verified raw query. Repeated keys become lists in wire order."""
184
+ result: dict[str, Union[str, list[str]]] = {}
185
+ for key, value in parse_qsl(query, keep_blank_values=True, strict_parsing=False, errors="strict"):
186
+ existing = result.get(key)
187
+ if existing is None:
188
+ result[key] = value
189
+ elif isinstance(existing, list):
190
+ existing.append(value)
191
+ else:
192
+ result[key] = [existing, value]
193
+ return result
194
+
195
+
196
+ def parse_verified_json_body(body: bytes, content_type: Optional[str]) -> tuple[bool, Any]:
197
+ """``(True, value)`` or ``(False, error)``. Empty means ``None``; otherwise
198
+ strict UTF-8 JSON declared as ``application/json``."""
199
+ if not body:
200
+ return True, None
201
+ if not isinstance(content_type, str) or not re.fullmatch(r"application/json(\s*;.*)?", content_type.strip(), re.IGNORECASE):
202
+ return False, "Signed request bodies must be application/json"
203
+ try:
204
+ return True, json.loads(
205
+ body.decode("utf-8"), object_pairs_hook=_unique_object,
206
+ parse_constant=_reject_constant, parse_float=_finite_float,
207
+ )
208
+ except (UnicodeDecodeError, ValueError, RecursionError):
209
+ return False, "Invalid JSON body"
210
+
211
+
212
+ def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
213
+ result: dict[str, Any] = {}
214
+ for key, value in pairs:
215
+ if key in result:
216
+ raise ValueError("Duplicate JSON key")
217
+ result[key] = value
218
+ return result
219
+
220
+
221
+ def _reject_constant(value: str) -> Any:
222
+ raise ValueError("Non-finite JSON number")
223
+
224
+
225
+ def _finite_float(value: str) -> float:
226
+ parsed = float(value)
227
+ if not math.isfinite(parsed):
228
+ raise ValueError("Non-finite JSON number")
229
+ return parsed
230
+
231
+
232
+ def route_relative_path(raw_path: str, route_template: str) -> Optional[str]:
233
+ """Last N raw segments of ``raw_path`` (N = the template's segment count)."""
234
+ count = len([part for part in route_template.split("/") if part])
235
+ if count == 0:
236
+ return "/"
237
+ segments = raw_path.split("/")
238
+ if len(segments) - 1 < count:
239
+ return None
240
+ return "/" + "/".join(segments[-count:])