sentinel-python-client 2.0.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.
- sentinel/__init__.py +69 -0
- sentinel/_async_client.py +56 -0
- sentinel/_client.py +56 -0
- sentinel/_exceptions.py +54 -0
- sentinel/_http.py +192 -0
- sentinel/_replay.py +32 -0
- sentinel/_signature.py +119 -0
- sentinel/models/__init__.py +24 -0
- sentinel/models/license.py +63 -0
- sentinel/models/page.py +15 -0
- sentinel/models/requests.py +184 -0
- sentinel/models/validation.py +102 -0
- sentinel/services/__init__.py +1 -0
- sentinel/services/_async_license.py +172 -0
- sentinel/services/_async_operations.py +80 -0
- sentinel/services/_license.py +193 -0
- sentinel/services/_operations.py +74 -0
- sentinel/util/__init__.py +5 -0
- sentinel/util/fingerprint.py +383 -0
- sentinel/util/public_ip.py +29 -0
- sentinel_python_client-2.0.0.dist-info/METADATA +41 -0
- sentinel_python_client-2.0.0.dist-info/RECORD +24 -0
- sentinel_python_client-2.0.0.dist-info/WHEEL +4 -0
- sentinel_python_client-2.0.0.dist-info/licenses/LICENSE +21 -0
sentinel/__init__.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Sentinel Python Client - Official client for the Sentinel v2 API."""
|
|
2
|
+
|
|
3
|
+
from sentinel._async_client import AsyncSentinelClient
|
|
4
|
+
from sentinel._client import SentinelClient
|
|
5
|
+
from sentinel._exceptions import (
|
|
6
|
+
LicenseValidationError,
|
|
7
|
+
ReplayDetectedError,
|
|
8
|
+
SentinelApiError,
|
|
9
|
+
SentinelConnectionError,
|
|
10
|
+
SentinelError,
|
|
11
|
+
SignatureVerificationError,
|
|
12
|
+
)
|
|
13
|
+
from sentinel.models.license import (
|
|
14
|
+
BlacklistInfo,
|
|
15
|
+
License,
|
|
16
|
+
LicenseIssuer,
|
|
17
|
+
LicenseProduct,
|
|
18
|
+
LicenseTier,
|
|
19
|
+
SubUser,
|
|
20
|
+
)
|
|
21
|
+
from sentinel.models.page import Page
|
|
22
|
+
from sentinel.models.requests import (
|
|
23
|
+
CLEAR,
|
|
24
|
+
CLEAR_EXPIRATION,
|
|
25
|
+
CreateLicenseRequest,
|
|
26
|
+
ListLicensesRequest,
|
|
27
|
+
UpdateLicenseRequest,
|
|
28
|
+
ValidationRequest,
|
|
29
|
+
)
|
|
30
|
+
from sentinel.models.validation import (
|
|
31
|
+
BlacklistFailureDetails,
|
|
32
|
+
ExcessiveIpsFailureDetails,
|
|
33
|
+
ExcessiveServersFailureDetails,
|
|
34
|
+
FailureDetails,
|
|
35
|
+
ValidationDetails,
|
|
36
|
+
ValidationResult,
|
|
37
|
+
ValidationResultType,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"SentinelClient",
|
|
42
|
+
"AsyncSentinelClient",
|
|
43
|
+
"SentinelError",
|
|
44
|
+
"SentinelApiError",
|
|
45
|
+
"SentinelConnectionError",
|
|
46
|
+
"LicenseValidationError",
|
|
47
|
+
"SignatureVerificationError",
|
|
48
|
+
"ReplayDetectedError",
|
|
49
|
+
"License",
|
|
50
|
+
"LicenseProduct",
|
|
51
|
+
"LicenseTier",
|
|
52
|
+
"LicenseIssuer",
|
|
53
|
+
"SubUser",
|
|
54
|
+
"BlacklistInfo",
|
|
55
|
+
"ValidationResult",
|
|
56
|
+
"ValidationResultType",
|
|
57
|
+
"ValidationDetails",
|
|
58
|
+
"FailureDetails",
|
|
59
|
+
"BlacklistFailureDetails",
|
|
60
|
+
"ExcessiveServersFailureDetails",
|
|
61
|
+
"ExcessiveIpsFailureDetails",
|
|
62
|
+
"CreateLicenseRequest",
|
|
63
|
+
"UpdateLicenseRequest",
|
|
64
|
+
"ListLicensesRequest",
|
|
65
|
+
"ValidationRequest",
|
|
66
|
+
"CLEAR",
|
|
67
|
+
"CLEAR_EXPIRATION",
|
|
68
|
+
"Page",
|
|
69
|
+
]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
|
|
5
|
+
from sentinel._http import AsyncSentinelHttpClient
|
|
6
|
+
from sentinel._replay import ReplayProtector
|
|
7
|
+
from sentinel._signature import SignatureVerifier
|
|
8
|
+
from sentinel.services._async_license import AsyncLicenseService
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AsyncSentinelClient:
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
base_url: str,
|
|
15
|
+
api_key: str,
|
|
16
|
+
public_key: str | None = None,
|
|
17
|
+
connect_timeout: float = 5.0,
|
|
18
|
+
read_timeout: float = 10.0,
|
|
19
|
+
replay_protection_window: timedelta = timedelta(seconds=30),
|
|
20
|
+
nonce_cache_size: int = 1000,
|
|
21
|
+
) -> None:
|
|
22
|
+
if not base_url:
|
|
23
|
+
raise ValueError("base_url is required")
|
|
24
|
+
if not api_key:
|
|
25
|
+
raise ValueError("api_key is required")
|
|
26
|
+
|
|
27
|
+
self._http = AsyncSentinelHttpClient(
|
|
28
|
+
base_url=base_url,
|
|
29
|
+
api_key=api_key,
|
|
30
|
+
connect_timeout=connect_timeout,
|
|
31
|
+
read_timeout=read_timeout,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
sig: SignatureVerifier | None = None
|
|
35
|
+
replay: ReplayProtector | None = None
|
|
36
|
+
if public_key is not None:
|
|
37
|
+
sig = SignatureVerifier(public_key)
|
|
38
|
+
replay = ReplayProtector(
|
|
39
|
+
window_seconds=replay_protection_window.total_seconds(),
|
|
40
|
+
max_size=nonce_cache_size,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
self.licenses = AsyncLicenseService(
|
|
44
|
+
http_client=self._http,
|
|
45
|
+
signature_verifier=sig,
|
|
46
|
+
replay_protector=replay,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
async def aclose(self) -> None:
|
|
50
|
+
await self._http.aclose()
|
|
51
|
+
|
|
52
|
+
async def __aenter__(self) -> AsyncSentinelClient:
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
async def __aexit__(self, *args: object) -> None:
|
|
56
|
+
await self.aclose()
|
sentinel/_client.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
|
|
5
|
+
from sentinel._http import SentinelHttpClient
|
|
6
|
+
from sentinel._replay import ReplayProtector
|
|
7
|
+
from sentinel._signature import SignatureVerifier
|
|
8
|
+
from sentinel.services._license import LicenseService
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SentinelClient:
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
base_url: str,
|
|
15
|
+
api_key: str,
|
|
16
|
+
public_key: str | None = None,
|
|
17
|
+
connect_timeout: float = 5.0,
|
|
18
|
+
read_timeout: float = 10.0,
|
|
19
|
+
replay_protection_window: timedelta = timedelta(seconds=30),
|
|
20
|
+
nonce_cache_size: int = 1000,
|
|
21
|
+
) -> None:
|
|
22
|
+
if not base_url:
|
|
23
|
+
raise ValueError("base_url is required")
|
|
24
|
+
if not api_key:
|
|
25
|
+
raise ValueError("api_key is required")
|
|
26
|
+
|
|
27
|
+
self._http = SentinelHttpClient(
|
|
28
|
+
base_url=base_url,
|
|
29
|
+
api_key=api_key,
|
|
30
|
+
connect_timeout=connect_timeout,
|
|
31
|
+
read_timeout=read_timeout,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
sig: SignatureVerifier | None = None
|
|
35
|
+
replay: ReplayProtector | None = None
|
|
36
|
+
if public_key is not None:
|
|
37
|
+
sig = SignatureVerifier(public_key)
|
|
38
|
+
replay = ReplayProtector(
|
|
39
|
+
window_seconds=replay_protection_window.total_seconds(),
|
|
40
|
+
max_size=nonce_cache_size,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
self.licenses = LicenseService(
|
|
44
|
+
http_client=self._http,
|
|
45
|
+
signature_verifier=sig,
|
|
46
|
+
replay_protector=replay,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def close(self) -> None:
|
|
50
|
+
self._http.close()
|
|
51
|
+
|
|
52
|
+
def __enter__(self) -> SentinelClient:
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
def __exit__(self, *args: object) -> None:
|
|
56
|
+
self.close()
|
sentinel/_exceptions.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from sentinel.models.validation import ValidationResultType
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SentinelError(Exception):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SentinelApiError(SentinelError):
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
http_status: int,
|
|
17
|
+
type: str | None,
|
|
18
|
+
message: str,
|
|
19
|
+
retry_after_seconds: float | None = None,
|
|
20
|
+
) -> None:
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.message = message
|
|
23
|
+
self.http_status = http_status
|
|
24
|
+
self.type = type
|
|
25
|
+
self.retry_after_seconds = retry_after_seconds
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SentinelConnectionError(SentinelError):
|
|
29
|
+
def __init__(self, message: str, cause: Exception | None = None) -> None:
|
|
30
|
+
super().__init__(message)
|
|
31
|
+
self.message = message
|
|
32
|
+
if cause is not None:
|
|
33
|
+
self.__cause__ = cause
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class LicenseValidationError(SentinelError):
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
type: ValidationResultType,
|
|
40
|
+
message: str,
|
|
41
|
+
failure_details: object | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
super().__init__(message)
|
|
44
|
+
self.message = message
|
|
45
|
+
self.type = type
|
|
46
|
+
self.failure_details = failure_details
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class SignatureVerificationError(SentinelError):
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ReplayDetectedError(SentinelError):
|
|
54
|
+
pass
|
sentinel/_http.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import urlencode
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from sentinel._exceptions import SentinelApiError, SentinelConnectionError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class ApiResponse:
|
|
15
|
+
http_status: int
|
|
16
|
+
type: str | None
|
|
17
|
+
message: str | None
|
|
18
|
+
result: dict[str, Any] | None
|
|
19
|
+
|
|
20
|
+
def require_result(self) -> dict[str, Any]:
|
|
21
|
+
if self.result is None:
|
|
22
|
+
raise SentinelApiError(
|
|
23
|
+
http_status=self.http_status,
|
|
24
|
+
type=self.type,
|
|
25
|
+
message="Response contained no result",
|
|
26
|
+
)
|
|
27
|
+
return self.result
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _parse_response(
|
|
31
|
+
status_code: int,
|
|
32
|
+
body: str,
|
|
33
|
+
headers: httpx.Headers,
|
|
34
|
+
allowed_statuses: set[int] | None,
|
|
35
|
+
) -> ApiResponse:
|
|
36
|
+
if status_code == 204:
|
|
37
|
+
return ApiResponse(http_status=204, type=None, message=None, result=None)
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
data = json.loads(body)
|
|
41
|
+
except (json.JSONDecodeError, ValueError) as e:
|
|
42
|
+
raise SentinelApiError(
|
|
43
|
+
http_status=status_code, type=None, message="Failed to parse response body"
|
|
44
|
+
) from e
|
|
45
|
+
|
|
46
|
+
resp_type = data.get("type")
|
|
47
|
+
message = data.get("message")
|
|
48
|
+
result = data.get("result") if isinstance(data.get("result"), dict) else None
|
|
49
|
+
|
|
50
|
+
if 200 <= status_code < 300:
|
|
51
|
+
return ApiResponse(http_status=status_code, type=resp_type, message=message, result=result)
|
|
52
|
+
|
|
53
|
+
if allowed_statuses and status_code in allowed_statuses:
|
|
54
|
+
return ApiResponse(http_status=status_code, type=resp_type, message=message, result=result)
|
|
55
|
+
|
|
56
|
+
retry_after: float | None = None
|
|
57
|
+
if status_code == 429:
|
|
58
|
+
raw = headers.get("X-Rate-Limit-Retry-After-Seconds")
|
|
59
|
+
if raw is not None:
|
|
60
|
+
try:
|
|
61
|
+
retry_after = int(raw)
|
|
62
|
+
except ValueError:
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
raise SentinelApiError(
|
|
66
|
+
http_status=status_code,
|
|
67
|
+
type=resp_type,
|
|
68
|
+
message=message or "Unknown error",
|
|
69
|
+
retry_after_seconds=retry_after,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _build_url(
|
|
74
|
+
base_url: str,
|
|
75
|
+
path: str,
|
|
76
|
+
query_params: dict[str, str] | None = None,
|
|
77
|
+
multi_query_params: dict[str, list[str]] | None = None,
|
|
78
|
+
) -> str:
|
|
79
|
+
url = base_url + path
|
|
80
|
+
if multi_query_params:
|
|
81
|
+
parts: list[str] = []
|
|
82
|
+
for key in sorted(multi_query_params):
|
|
83
|
+
for val in sorted(multi_query_params[key]):
|
|
84
|
+
parts.append(f"{urlencode({key: val})}")
|
|
85
|
+
url += "?" + "&".join(parts)
|
|
86
|
+
return url
|
|
87
|
+
if query_params:
|
|
88
|
+
sorted_params = sorted(query_params.items())
|
|
89
|
+
url += "?" + urlencode(sorted_params)
|
|
90
|
+
return url
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class SentinelHttpClient:
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
base_url: str,
|
|
97
|
+
api_key: str,
|
|
98
|
+
connect_timeout: float = 5.0,
|
|
99
|
+
read_timeout: float = 10.0,
|
|
100
|
+
http_client: httpx.Client | None = None,
|
|
101
|
+
) -> None:
|
|
102
|
+
self._base_url = base_url.rstrip("/")
|
|
103
|
+
self._api_key = api_key
|
|
104
|
+
if http_client is not None:
|
|
105
|
+
self._client = http_client
|
|
106
|
+
else:
|
|
107
|
+
self._client = httpx.Client(
|
|
108
|
+
timeout=httpx.Timeout(
|
|
109
|
+
connect=connect_timeout, read=read_timeout, write=5.0, pool=5.0
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def request(
|
|
114
|
+
self,
|
|
115
|
+
method: str,
|
|
116
|
+
path: str,
|
|
117
|
+
json_body: dict[str, Any] | list[Any] | None = None,
|
|
118
|
+
query_params: dict[str, str] | None = None,
|
|
119
|
+
multi_query_params: dict[str, list[str]] | None = None,
|
|
120
|
+
allowed_statuses: set[int] | None = None,
|
|
121
|
+
) -> ApiResponse:
|
|
122
|
+
url = _build_url(self._base_url, path, query_params, multi_query_params)
|
|
123
|
+
headers = {"Authorization": f"Bearer {self._api_key}"}
|
|
124
|
+
kwargs: dict[str, Any] = {"headers": headers}
|
|
125
|
+
|
|
126
|
+
if json_body is not None:
|
|
127
|
+
headers["Content-Type"] = "application/json"
|
|
128
|
+
kwargs["content"] = json.dumps(json_body)
|
|
129
|
+
try:
|
|
130
|
+
response = self._client.request(method, url, **kwargs)
|
|
131
|
+
except httpx.TimeoutException as e:
|
|
132
|
+
raise SentinelConnectionError("Failed to connect to Sentinel API", e) from e
|
|
133
|
+
except httpx.NetworkError as e:
|
|
134
|
+
raise SentinelConnectionError("Failed to connect to Sentinel API", e) from e
|
|
135
|
+
|
|
136
|
+
return _parse_response(
|
|
137
|
+
response.status_code, response.text, response.headers, allowed_statuses
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def close(self) -> None:
|
|
141
|
+
self._client.close()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class AsyncSentinelHttpClient:
|
|
145
|
+
def __init__(
|
|
146
|
+
self,
|
|
147
|
+
base_url: str,
|
|
148
|
+
api_key: str,
|
|
149
|
+
connect_timeout: float = 5.0,
|
|
150
|
+
read_timeout: float = 10.0,
|
|
151
|
+
http_client: httpx.AsyncClient | None = None,
|
|
152
|
+
) -> None:
|
|
153
|
+
self._base_url = base_url.rstrip("/")
|
|
154
|
+
self._api_key = api_key
|
|
155
|
+
if http_client is not None:
|
|
156
|
+
self._client = http_client
|
|
157
|
+
else:
|
|
158
|
+
self._client = httpx.AsyncClient(
|
|
159
|
+
timeout=httpx.Timeout(
|
|
160
|
+
connect=connect_timeout, read=read_timeout, write=5.0, pool=5.0
|
|
161
|
+
),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
async def request(
|
|
165
|
+
self,
|
|
166
|
+
method: str,
|
|
167
|
+
path: str,
|
|
168
|
+
json_body: dict[str, Any] | list[Any] | None = None,
|
|
169
|
+
query_params: dict[str, str] | None = None,
|
|
170
|
+
multi_query_params: dict[str, list[str]] | None = None,
|
|
171
|
+
allowed_statuses: set[int] | None = None,
|
|
172
|
+
) -> ApiResponse:
|
|
173
|
+
url = _build_url(self._base_url, path, query_params, multi_query_params)
|
|
174
|
+
headers = {"Authorization": f"Bearer {self._api_key}"}
|
|
175
|
+
kwargs: dict[str, Any] = {"headers": headers}
|
|
176
|
+
|
|
177
|
+
if json_body is not None:
|
|
178
|
+
headers["Content-Type"] = "application/json"
|
|
179
|
+
kwargs["content"] = json.dumps(json_body)
|
|
180
|
+
try:
|
|
181
|
+
response = await self._client.request(method, url, **kwargs)
|
|
182
|
+
except httpx.TimeoutException as e:
|
|
183
|
+
raise SentinelConnectionError("Failed to connect to Sentinel API", e) from e
|
|
184
|
+
except httpx.NetworkError as e:
|
|
185
|
+
raise SentinelConnectionError("Failed to connect to Sentinel API", e) from e
|
|
186
|
+
|
|
187
|
+
return _parse_response(
|
|
188
|
+
response.status_code, response.text, response.headers, allowed_statuses
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
async def aclose(self) -> None:
|
|
192
|
+
await self._client.aclose()
|
sentinel/_replay.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from collections import OrderedDict
|
|
6
|
+
|
|
7
|
+
from sentinel._exceptions import ReplayDetectedError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ReplayProtector:
|
|
11
|
+
def __init__(self, window_seconds: float, max_size: int) -> None:
|
|
12
|
+
self._window_ms = window_seconds * 1000
|
|
13
|
+
self._max_size = max_size
|
|
14
|
+
self._nonce_cache: OrderedDict[str, bool] = OrderedDict()
|
|
15
|
+
self._lock = threading.Lock()
|
|
16
|
+
|
|
17
|
+
def check(self, nonce: str, response_timestamp_ms: int) -> None:
|
|
18
|
+
now = time.time() * 1000
|
|
19
|
+
drift = abs(now - response_timestamp_ms)
|
|
20
|
+
|
|
21
|
+
if drift > self._window_ms:
|
|
22
|
+
raise ReplayDetectedError(
|
|
23
|
+
f"Response timestamp is outside the acceptable window: "
|
|
24
|
+
f"drift={int(drift)}ms, window={int(self._window_ms)}ms"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
with self._lock:
|
|
28
|
+
if nonce in self._nonce_cache:
|
|
29
|
+
raise ReplayDetectedError(f"Duplicate nonce detected: {nonce}")
|
|
30
|
+
self._nonce_cache[nonce] = True
|
|
31
|
+
while len(self._nonce_cache) > self._max_size:
|
|
32
|
+
self._nonce_cache.popitem(last=False)
|
sentinel/_signature.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
|
|
5
|
+
from nacl.exceptions import BadSignatureError
|
|
6
|
+
from nacl.signing import VerifyKey
|
|
7
|
+
|
|
8
|
+
from sentinel._exceptions import SignatureVerificationError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SignatureVerifier:
|
|
12
|
+
# X.509 SubjectPublicKeyInfo prefix for Ed25519 (OID 1.3.101.112)
|
|
13
|
+
_SPKI_PREFIX = bytes.fromhex("302a300506032b6570032100")
|
|
14
|
+
|
|
15
|
+
def __init__(self, base64_public_key: str) -> None:
|
|
16
|
+
try:
|
|
17
|
+
key_bytes = base64.b64decode(base64_public_key)
|
|
18
|
+
if key_bytes[:12] == self._SPKI_PREFIX:
|
|
19
|
+
raw_key = key_bytes[12:]
|
|
20
|
+
else:
|
|
21
|
+
raw_key = key_bytes
|
|
22
|
+
self._verify_key = VerifyKey(raw_key)
|
|
23
|
+
except Exception as e:
|
|
24
|
+
raise ValueError(f"Invalid Ed25519 public key: {e}") from e
|
|
25
|
+
|
|
26
|
+
def verify(
|
|
27
|
+
self,
|
|
28
|
+
signature_base64: str | None,
|
|
29
|
+
nonce: str,
|
|
30
|
+
timestamp: int,
|
|
31
|
+
expiration: str | None,
|
|
32
|
+
server_count: int,
|
|
33
|
+
max_servers: int,
|
|
34
|
+
ip_count: int,
|
|
35
|
+
max_ips: int,
|
|
36
|
+
tier: str | None,
|
|
37
|
+
entitlements: list[str] | None,
|
|
38
|
+
) -> None:
|
|
39
|
+
if signature_base64 is None:
|
|
40
|
+
raise SignatureVerificationError(
|
|
41
|
+
"Response signature is null but signature verification is enabled"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
canonical = self.build_canonical_payload(
|
|
45
|
+
nonce=nonce,
|
|
46
|
+
timestamp=timestamp,
|
|
47
|
+
expiration=expiration,
|
|
48
|
+
server_count=server_count,
|
|
49
|
+
max_servers=max_servers,
|
|
50
|
+
ip_count=ip_count,
|
|
51
|
+
max_ips=max_ips,
|
|
52
|
+
tier=tier,
|
|
53
|
+
entitlements=entitlements,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
sig_bytes = base64.b64decode(signature_base64)
|
|
58
|
+
self._verify_key.verify(canonical.encode("utf-8"), sig_bytes)
|
|
59
|
+
except BadSignatureError:
|
|
60
|
+
raise SignatureVerificationError("Signature verification failed")
|
|
61
|
+
except SignatureVerificationError:
|
|
62
|
+
raise
|
|
63
|
+
except Exception as e:
|
|
64
|
+
raise SignatureVerificationError(f"Signature verification error: {e}") from e
|
|
65
|
+
|
|
66
|
+
def build_canonical_payload(
|
|
67
|
+
self,
|
|
68
|
+
nonce: str,
|
|
69
|
+
timestamp: int,
|
|
70
|
+
expiration: str | None,
|
|
71
|
+
server_count: int,
|
|
72
|
+
max_servers: int,
|
|
73
|
+
ip_count: int,
|
|
74
|
+
max_ips: int,
|
|
75
|
+
tier: str | None,
|
|
76
|
+
entitlements: list[str] | None,
|
|
77
|
+
) -> str:
|
|
78
|
+
parts: list[str] = ["{"]
|
|
79
|
+
parts.append('"entitlements":')
|
|
80
|
+
if entitlements is None:
|
|
81
|
+
parts.append("null")
|
|
82
|
+
else:
|
|
83
|
+
sorted_ent = sorted(entitlements)
|
|
84
|
+
parts.append("[")
|
|
85
|
+
parts.append(",".join(_json_string(e) for e in sorted_ent))
|
|
86
|
+
parts.append("]")
|
|
87
|
+
parts.append(f',"expiration":{_json_string(expiration)}')
|
|
88
|
+
parts.append(f',"ipCount":{ip_count}')
|
|
89
|
+
parts.append(f',"maxIps":{max_ips}')
|
|
90
|
+
parts.append(f',"maxServers":{max_servers}')
|
|
91
|
+
parts.append(f',"nonce":{_json_string(nonce)}')
|
|
92
|
+
parts.append(f',"serverCount":{server_count}')
|
|
93
|
+
parts.append(f',"tier":{_json_string(tier)}')
|
|
94
|
+
parts.append(f',"timestamp":{timestamp}')
|
|
95
|
+
parts.append("}")
|
|
96
|
+
return "".join(parts)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _json_string(value: str | None) -> str:
|
|
100
|
+
if value is None:
|
|
101
|
+
return "null"
|
|
102
|
+
result = ['"']
|
|
103
|
+
for ch in value:
|
|
104
|
+
if ch == '"':
|
|
105
|
+
result.append('\\"')
|
|
106
|
+
elif ch == "\\":
|
|
107
|
+
result.append("\\\\")
|
|
108
|
+
elif ch == "\n":
|
|
109
|
+
result.append("\\n")
|
|
110
|
+
elif ch == "\r":
|
|
111
|
+
result.append("\\r")
|
|
112
|
+
elif ch == "\t":
|
|
113
|
+
result.append("\\t")
|
|
114
|
+
elif ord(ch) < 0x20:
|
|
115
|
+
result.append(f"\\u{ord(ch):04x}")
|
|
116
|
+
else:
|
|
117
|
+
result.append(ch)
|
|
118
|
+
result.append('"')
|
|
119
|
+
return "".join(result)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Sentinel API models."""
|
|
2
|
+
|
|
3
|
+
from sentinel.models.license import BlacklistInfo as BlacklistInfo
|
|
4
|
+
from sentinel.models.license import License as License
|
|
5
|
+
from sentinel.models.license import LicenseIssuer as LicenseIssuer
|
|
6
|
+
from sentinel.models.license import LicenseProduct as LicenseProduct
|
|
7
|
+
from sentinel.models.license import LicenseTier as LicenseTier
|
|
8
|
+
from sentinel.models.license import SubUser as SubUser
|
|
9
|
+
from sentinel.models.page import Page as Page
|
|
10
|
+
from sentinel.models.requests import CLEAR as CLEAR
|
|
11
|
+
from sentinel.models.requests import CLEAR_EXPIRATION as CLEAR_EXPIRATION
|
|
12
|
+
from sentinel.models.requests import CreateLicenseRequest as CreateLicenseRequest
|
|
13
|
+
from sentinel.models.requests import ListLicensesRequest as ListLicensesRequest
|
|
14
|
+
from sentinel.models.requests import UpdateLicenseRequest as UpdateLicenseRequest
|
|
15
|
+
from sentinel.models.requests import ValidationRequest as ValidationRequest
|
|
16
|
+
from sentinel.models.validation import BlacklistFailureDetails as BlacklistFailureDetails
|
|
17
|
+
from sentinel.models.validation import ExcessiveIpsFailureDetails as ExcessiveIpsFailureDetails
|
|
18
|
+
from sentinel.models.validation import (
|
|
19
|
+
ExcessiveServersFailureDetails as ExcessiveServersFailureDetails,
|
|
20
|
+
)
|
|
21
|
+
from sentinel.models.validation import FailureDetails as FailureDetails
|
|
22
|
+
from sentinel.models.validation import ValidationDetails as ValidationDetails
|
|
23
|
+
from sentinel.models.validation import ValidationResult as ValidationResult
|
|
24
|
+
from sentinel.models.validation import ValidationResultType as ValidationResultType
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict
|
|
6
|
+
from pydantic.alias_generators import to_camel
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class _CamelModel(BaseModel):
|
|
10
|
+
model_config = ConfigDict(
|
|
11
|
+
alias_generator=to_camel,
|
|
12
|
+
populate_by_name=True,
|
|
13
|
+
frozen=True,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LicenseProduct(_CamelModel):
|
|
18
|
+
id: str
|
|
19
|
+
name: str
|
|
20
|
+
description: str | None = None
|
|
21
|
+
logo_url: str | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class LicenseTier(_CamelModel):
|
|
25
|
+
id: str
|
|
26
|
+
name: str | None = None
|
|
27
|
+
entitlements: set[str] = set()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LicenseIssuer(_CamelModel):
|
|
31
|
+
type: str
|
|
32
|
+
id: str
|
|
33
|
+
display_name: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SubUser(_CamelModel):
|
|
37
|
+
platform: str
|
|
38
|
+
value: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class BlacklistInfo(_CamelModel):
|
|
42
|
+
timestamp: datetime
|
|
43
|
+
reason: str | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class License(_CamelModel):
|
|
47
|
+
id: str
|
|
48
|
+
key: str
|
|
49
|
+
product: LicenseProduct
|
|
50
|
+
tier: LicenseTier | None = None
|
|
51
|
+
issuer: LicenseIssuer
|
|
52
|
+
created_at: datetime
|
|
53
|
+
expiration: datetime | None = None
|
|
54
|
+
max_servers: int
|
|
55
|
+
max_ips: int
|
|
56
|
+
note: str | None = None
|
|
57
|
+
connections: dict[str, str] = {}
|
|
58
|
+
sub_users: list[SubUser] = []
|
|
59
|
+
servers: dict[str, datetime] = {}
|
|
60
|
+
ips: dict[str, datetime] = {}
|
|
61
|
+
additional_entitlements: set[str] = set()
|
|
62
|
+
entitlements: set[str] = set()
|
|
63
|
+
blacklist: BlacklistInfo | None = None
|
sentinel/models/page.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Generic, TypeVar
|
|
5
|
+
|
|
6
|
+
T = TypeVar("T")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class Page(Generic[T]):
|
|
11
|
+
content: list[T]
|
|
12
|
+
size: int
|
|
13
|
+
number: int
|
|
14
|
+
total_elements: int
|
|
15
|
+
total_pages: int
|