licantha-sdk 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.
- licantha_sdk/__init__.py +44 -0
- licantha_sdk/__main__.py +53 -0
- licantha_sdk/__version__.py +1 -0
- licantha_sdk/_base.py +479 -0
- licantha_sdk/_crypto.py +147 -0
- licantha_sdk/_errors.py +100 -0
- licantha_sdk/_fingerprint.py +19 -0
- licantha_sdk/_heartbeat.py +97 -0
- licantha_sdk/_keygen.py +345 -0
- licantha_sdk/_offline.py +175 -0
- licantha_sdk/_providers.py +131 -0
- licantha_sdk/_utils.py +63 -0
- licantha_sdk/config.py +111 -0
- licantha_sdk-0.1.0.dist-info/METADATA +340 -0
- licantha_sdk-0.1.0.dist-info/RECORD +18 -0
- licantha_sdk-0.1.0.dist-info/WHEEL +5 -0
- licantha_sdk-0.1.0.dist-info/licenses/LICENSE.txt +7 -0
- licantha_sdk-0.1.0.dist-info/top_level.txt +1 -0
licantha_sdk/__init__.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Licantha SDK — license-gated model decryption for on-prem AI services.
|
|
2
|
+
|
|
3
|
+
Public API:
|
|
4
|
+
SecureClient — entry point; activates, heartbeats, decrypts models.
|
|
5
|
+
SecureClientConfigPM — pydantic config model.
|
|
6
|
+
SecureClientCliConfig — same fields, but loads from `.env` / env vars.
|
|
7
|
+
KeyProvider — abstract source of AES-256-GCM model keys.
|
|
8
|
+
LicenseError + leaves — actionable, customer-facing exceptions.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .__version__ import __version__
|
|
12
|
+
from ._base import SecureClient
|
|
13
|
+
from ._errors import (
|
|
14
|
+
ActivationError,
|
|
15
|
+
HeartbeatError,
|
|
16
|
+
KeyUnavailableError,
|
|
17
|
+
LicenseError,
|
|
18
|
+
LicenseExpiredError,
|
|
19
|
+
LicenseSuspendedError,
|
|
20
|
+
MachineMismatchError,
|
|
21
|
+
OfflineLicenseError,
|
|
22
|
+
ValidationError,
|
|
23
|
+
)
|
|
24
|
+
from ._providers import KeyProvider, LeaseKeyProvider
|
|
25
|
+
from .config import SecureClientConfigPM, SecureClientCliConfig
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"__version__",
|
|
30
|
+
"SecureClient",
|
|
31
|
+
"SecureClientConfigPM",
|
|
32
|
+
"SecureClientCliConfig",
|
|
33
|
+
"KeyProvider",
|
|
34
|
+
"LeaseKeyProvider",
|
|
35
|
+
"LicenseError",
|
|
36
|
+
"ActivationError",
|
|
37
|
+
"HeartbeatError",
|
|
38
|
+
"ValidationError",
|
|
39
|
+
"OfflineLicenseError",
|
|
40
|
+
"KeyUnavailableError",
|
|
41
|
+
"MachineMismatchError",
|
|
42
|
+
"LicenseExpiredError",
|
|
43
|
+
"LicenseSuspendedError",
|
|
44
|
+
]
|
licantha_sdk/__main__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Standard libraries
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
# Third-party libraries
|
|
7
|
+
from dotenv import load_dotenv
|
|
8
|
+
|
|
9
|
+
# Internal modules
|
|
10
|
+
from ._base import SecureClient
|
|
11
|
+
from .config import SecureClientCliConfig
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
load_dotenv(dotenv_path=".env", override=True)
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main() -> None:
|
|
19
|
+
"""CLI smoke test — activate the SDK against the configured license server.
|
|
20
|
+
|
|
21
|
+
Usage:
|
|
22
|
+
LICANTHA_SDK_ACCOUNT_ID=... LICANTHA_SDK_LICENSE_KEY=... \\
|
|
23
|
+
LICANTHA_SDK_PUBLIC_KEY=... python -m licantha_sdk
|
|
24
|
+
|
|
25
|
+
Prints the resolved license_id, machine_id, fingerprint, and the
|
|
26
|
+
available model_ids in the key cache, then deactivates.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
_log_level = logging.INFO
|
|
30
|
+
if str(os.getenv("DEBUG", "0")).lower() in ("1", "true", "t", "yes", "y"):
|
|
31
|
+
_log_level = logging.DEBUG
|
|
32
|
+
|
|
33
|
+
logging.basicConfig(
|
|
34
|
+
stream=sys.stdout,
|
|
35
|
+
level=_log_level,
|
|
36
|
+
datefmt="%Y-%m-%d %H:%M:%S %z",
|
|
37
|
+
format="[%(asctime)s | %(levelname)s | %(filename)s:%(lineno)d]: %(message)s",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
_config = SecureClientCliConfig() # type: ignore[call-arg]
|
|
41
|
+
with SecureClient(config=_config) as _client:
|
|
42
|
+
logger.info(f"fingerprint = {_client.fingerprint}")
|
|
43
|
+
logger.info(f"license_id = {_client.license_id}")
|
|
44
|
+
logger.info(f"machine_id = {_client.machine_id}")
|
|
45
|
+
# Touch the key cache via the private snapshot, for visibility.
|
|
46
|
+
_snapshot = _client.key_provider._snapshot # type: ignore[attr-defined]
|
|
47
|
+
logger.info(f"cached keys = {list(_snapshot.keys())}")
|
|
48
|
+
|
|
49
|
+
logger.info("Done!\n")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__":
|
|
53
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
licantha_sdk/_base.py
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
"""SecureClient — the public entry point.
|
|
2
|
+
|
|
3
|
+
Orchestrates license validation, machine activation, the background
|
|
4
|
+
heartbeat daemon, license-metadata-driven key provisioning, and
|
|
5
|
+
AES-256-GCM model decryption.
|
|
6
|
+
|
|
7
|
+
Online mode talks to Keygen; offline mode verifies an Ed25519-signed `.lic`
|
|
8
|
+
file shipped alongside the binary.
|
|
9
|
+
|
|
10
|
+
Lifecycle::
|
|
11
|
+
|
|
12
|
+
client = SecureClient(...)
|
|
13
|
+
client.activate() # validate + activate + prime + heartbeat
|
|
14
|
+
plaintext = client.decrypt_model("model.enc")
|
|
15
|
+
client.deactivate() # stop heartbeat + release slot
|
|
16
|
+
|
|
17
|
+
# Or, equivalent and exception-safe:
|
|
18
|
+
with SecureClient(...) as client:
|
|
19
|
+
plaintext = client.decrypt_model("model.enc")
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
# Standard libraries
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import pprint
|
|
26
|
+
import logging
|
|
27
|
+
from types import TracebackType
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
# Third-party libraries
|
|
31
|
+
from pydantic import validate_call
|
|
32
|
+
|
|
33
|
+
# Internal modules
|
|
34
|
+
from . import _crypto, _keygen
|
|
35
|
+
from . import _utils as utils
|
|
36
|
+
from .__version__ import __version__
|
|
37
|
+
from ._errors import (
|
|
38
|
+
ActivationError,
|
|
39
|
+
KeyUnavailableError,
|
|
40
|
+
LicenseExpiredError,
|
|
41
|
+
LicenseSuspendedError,
|
|
42
|
+
ValidationError,
|
|
43
|
+
)
|
|
44
|
+
from ._fingerprint import fingerprint as _fingerprint_fn
|
|
45
|
+
from ._heartbeat import HeartbeatDaemon
|
|
46
|
+
from ._offline import OfflineLicense, load_offline_license
|
|
47
|
+
from ._providers import KeyProvider, LeaseKeyProvider
|
|
48
|
+
from .config import SecureClientConfigPM
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
logger = logging.getLogger(__name__)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_TERMINAL_CODES = {
|
|
55
|
+
"EXPIRED": LicenseExpiredError,
|
|
56
|
+
"SUSPENDED": LicenseSuspendedError,
|
|
57
|
+
}
|
|
58
|
+
_ACTIVATION_NEEDED_CODES = {
|
|
59
|
+
"NO_MACHINE",
|
|
60
|
+
"NO_MACHINES",
|
|
61
|
+
"FINGERPRINT_SCOPE_MISMATCH",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class SecureClient:
|
|
66
|
+
"""Entry point for license-gated model loading.
|
|
67
|
+
|
|
68
|
+
Online mode (default): activates with Keygen, runs a background
|
|
69
|
+
heartbeat, pulls model keys from signed license metadata.
|
|
70
|
+
|
|
71
|
+
Offline mode: pass `offline_license_path` on the config. No network
|
|
72
|
+
calls are made. Keys come from an Ed25519-signed, AES-256-GCM-encrypted
|
|
73
|
+
license file (Keygen's cryptographic license file format).
|
|
74
|
+
|
|
75
|
+
Use as a context manager whenever possible — it guarantees the
|
|
76
|
+
heartbeat daemon is stopped and the machine deactivated on exit.
|
|
77
|
+
|
|
78
|
+
Attributes:
|
|
79
|
+
config (SecureClientConfigPM): The active configuration.
|
|
80
|
+
|
|
81
|
+
Methods:
|
|
82
|
+
activate(): Validate + activate + prime keys + start heartbeat.
|
|
83
|
+
decrypt_model(): Decrypt an AES-256-GCM model envelope into memory.
|
|
84
|
+
deactivate(): Release this machine's activation slot.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
@validate_call(config={"arbitrary_types_allowed": True})
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
config: SecureClientConfigPM | dict[str, Any] | None = None,
|
|
91
|
+
*,
|
|
92
|
+
auto_activate: bool = False,
|
|
93
|
+
**kwargs: Any,
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Initialize a `SecureClient`.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
config (SecureClientConfigPM | dict | None, optional): Config object,
|
|
99
|
+
or a dict the SDK will coerce. Defaults to None
|
|
100
|
+
(which only works if every required field is in `kwargs`).
|
|
101
|
+
auto_activate (bool , optional): If True, call
|
|
102
|
+
`activate()` at the end of `__init__`. Defaults to False.
|
|
103
|
+
**kwargs : Override individual
|
|
104
|
+
config fields (e.g. `license_key="..."`).
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
logger.debug(
|
|
108
|
+
f"Initializing <{self.__class__.__name__}> object with '{__version__}' version..."
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if config is None:
|
|
112
|
+
config = SecureClientConfigPM(**kwargs) if kwargs else None
|
|
113
|
+
elif kwargs:
|
|
114
|
+
if isinstance(config, dict):
|
|
115
|
+
config = {**config, **kwargs}
|
|
116
|
+
else:
|
|
117
|
+
config = config.model_copy(update=kwargs)
|
|
118
|
+
|
|
119
|
+
if config is None:
|
|
120
|
+
raise ValueError(
|
|
121
|
+
"`config` is required — pass a SecureClientConfigPM, a dict, or kwargs."
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
self.config = config
|
|
125
|
+
|
|
126
|
+
self._fingerprint = _fingerprint_fn(self.config.app_id)
|
|
127
|
+
self._session = _keygen.make_session(
|
|
128
|
+
user_agent=f"licantha-sdk/{__version__}"
|
|
129
|
+
)
|
|
130
|
+
self._key_provider: LeaseKeyProvider = LeaseKeyProvider()
|
|
131
|
+
self._heartbeat: HeartbeatDaemon | None = None
|
|
132
|
+
|
|
133
|
+
self._activated = False
|
|
134
|
+
self._license_id: str | None = None
|
|
135
|
+
self._machine_id: str | None = None
|
|
136
|
+
self._offline_license: OfflineLicense | None = None
|
|
137
|
+
|
|
138
|
+
logger.debug(
|
|
139
|
+
f"Initialized <{self.__class__.__name__}> object with '{__version__}' version."
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
if auto_activate:
|
|
143
|
+
self.activate()
|
|
144
|
+
|
|
145
|
+
# LIFECYCLE
|
|
146
|
+
def activate(self, *, start_heartbeat: bool = True) -> None:
|
|
147
|
+
"""Validate the license, activate this machine if needed, prime keys.
|
|
148
|
+
|
|
149
|
+
Idempotent — calling twice is a no-op.
|
|
150
|
+
|
|
151
|
+
Offline mode: verifies the license file's Ed25519 signature,
|
|
152
|
+
decrypts the AES-256-GCM payload with the license key as the KDF
|
|
153
|
+
input, and pulls keys from the embedded metadata. No network calls.
|
|
154
|
+
|
|
155
|
+
Raises:
|
|
156
|
+
ActivationError, ValidationError, OfflineLicenseError,
|
|
157
|
+
LicenseExpiredError, LicenseSuspendedError.
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
if self._activated:
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
if self.config.offline_license_path is not None:
|
|
164
|
+
self._activate_offline()
|
|
165
|
+
else:
|
|
166
|
+
self._activate_online()
|
|
167
|
+
if start_heartbeat and self._machine_id is not None:
|
|
168
|
+
self.start_heartbeat()
|
|
169
|
+
|
|
170
|
+
self._activated = True
|
|
171
|
+
|
|
172
|
+
def start_heartbeat(self) -> None:
|
|
173
|
+
"""Start the daemon heartbeat thread. No-op offline or when running."""
|
|
174
|
+
|
|
175
|
+
if self.config.offline_license_path is not None:
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
if self._machine_id is None:
|
|
179
|
+
raise ActivationError(
|
|
180
|
+
detail="cannot start heartbeat before activation",
|
|
181
|
+
code="NOT_ACTIVATED",
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
_hb = self._heartbeat
|
|
185
|
+
if _hb is None:
|
|
186
|
+
_hb = HeartbeatDaemon(
|
|
187
|
+
interval=self.config.heartbeat_interval,
|
|
188
|
+
ping=self._ping_once,
|
|
189
|
+
)
|
|
190
|
+
self._heartbeat = _hb
|
|
191
|
+
_hb.start()
|
|
192
|
+
|
|
193
|
+
def stop_heartbeat(self, *, timeout: float = 5.0) -> None:
|
|
194
|
+
"""Signal the heartbeat thread to stop and join within `timeout` seconds."""
|
|
195
|
+
|
|
196
|
+
if self._heartbeat is not None:
|
|
197
|
+
self._heartbeat.stop(timeout=timeout)
|
|
198
|
+
|
|
199
|
+
def deactivate(self) -> None:
|
|
200
|
+
"""Remove this machine's activation. No-op offline or when never activated."""
|
|
201
|
+
|
|
202
|
+
if self.config.offline_license_path is not None or self._machine_id is None:
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
_keygen.deactivate_machine(
|
|
207
|
+
session=self._session,
|
|
208
|
+
api_url=self._api_url,
|
|
209
|
+
account_id=self.config.account_id,
|
|
210
|
+
license_key=self.config.license_key,
|
|
211
|
+
machine_id=self._machine_id,
|
|
212
|
+
)
|
|
213
|
+
finally:
|
|
214
|
+
self._machine_id = None
|
|
215
|
+
self._activated = False
|
|
216
|
+
# LIFECYCLE
|
|
217
|
+
|
|
218
|
+
# KEY + MODEL
|
|
219
|
+
def decrypt_model(self, path: str, *, model_id: str | None = None) -> bytes:
|
|
220
|
+
"""Decrypt an AES-256-GCM-encrypted model envelope into memory.
|
|
221
|
+
|
|
222
|
+
If `model_id` is None, the envelope's embedded model_id is used to
|
|
223
|
+
select the key. Plaintext is never written to disk by the SDK.
|
|
224
|
+
|
|
225
|
+
Raises:
|
|
226
|
+
ActivationError: `activate()` has not been called.
|
|
227
|
+
KeyUnavailableError: no key for the requested model.
|
|
228
|
+
ValueError: envelope is malformed or tag verification failed.
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
if not self._activated:
|
|
232
|
+
raise ActivationError(
|
|
233
|
+
detail="decrypt_model called before activate()",
|
|
234
|
+
code="NOT_ACTIVATED",
|
|
235
|
+
remediation="Call client.activate() (or use a 'with' block) before decrypting.",
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
if model_id is None:
|
|
239
|
+
model_id = _crypto.read_model_id(path)
|
|
240
|
+
_key = self._key_provider.get_model_key(model_id)
|
|
241
|
+
return _crypto.decrypt_to_memory(path, _key)
|
|
242
|
+
# KEY + MODEL
|
|
243
|
+
|
|
244
|
+
# ATTRIBUTES
|
|
245
|
+
# config
|
|
246
|
+
@property
|
|
247
|
+
def config(self) -> SecureClientConfigPM:
|
|
248
|
+
try:
|
|
249
|
+
return self.__config
|
|
250
|
+
except AttributeError as _e:
|
|
251
|
+
raise AttributeError("`config` attribute is not set!") from _e
|
|
252
|
+
|
|
253
|
+
@config.setter
|
|
254
|
+
def config(self, config: SecureClientConfigPM | dict[str, Any]) -> None:
|
|
255
|
+
if (not isinstance(config, SecureClientConfigPM)) and (not isinstance(config, dict)):
|
|
256
|
+
raise TypeError(
|
|
257
|
+
f"`config` attribute type {type(config)} is invalid, "
|
|
258
|
+
"must be a <class 'SecureClientConfigPM'> or <dict>!"
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
if isinstance(config, dict):
|
|
262
|
+
config = SecureClientConfigPM(**config)
|
|
263
|
+
else:
|
|
264
|
+
config = config.model_copy(deep=True)
|
|
265
|
+
|
|
266
|
+
self.__config = config
|
|
267
|
+
# config
|
|
268
|
+
|
|
269
|
+
# key_provider
|
|
270
|
+
@property
|
|
271
|
+
def key_provider(self) -> KeyProvider:
|
|
272
|
+
"""The KeyProvider backing this client. Same instance across calls."""
|
|
273
|
+
return self._key_provider
|
|
274
|
+
# key_provider
|
|
275
|
+
|
|
276
|
+
# fingerprint
|
|
277
|
+
@property
|
|
278
|
+
def fingerprint(self) -> str:
|
|
279
|
+
"""Hardware fingerprint of this machine for `config.app_id`."""
|
|
280
|
+
return self._fingerprint
|
|
281
|
+
# fingerprint
|
|
282
|
+
|
|
283
|
+
# license_id
|
|
284
|
+
@property
|
|
285
|
+
def license_id(self) -> str | None:
|
|
286
|
+
"""The resolved license UUID, or None before activation."""
|
|
287
|
+
return self._license_id
|
|
288
|
+
# license_id
|
|
289
|
+
|
|
290
|
+
# machine_id
|
|
291
|
+
@property
|
|
292
|
+
def machine_id(self) -> str | None:
|
|
293
|
+
"""The activated machine UUID, or None offline/before activation."""
|
|
294
|
+
return self._machine_id
|
|
295
|
+
# machine_id
|
|
296
|
+
# ATTRIBUTES
|
|
297
|
+
|
|
298
|
+
# INTERNAL
|
|
299
|
+
@property
|
|
300
|
+
def _api_url(self) -> str:
|
|
301
|
+
# Tolerate api_url given with or without a trailing /v1 — the SDK
|
|
302
|
+
# appends /v1 itself at every call site, so we normalise here.
|
|
303
|
+
# Guaranteed non-None for online activation by the config validator.
|
|
304
|
+
if not self.config.api_url:
|
|
305
|
+
raise ValueError("api_url is required for online activation")
|
|
306
|
+
_base = self.config.api_url.rstrip("/")
|
|
307
|
+
if _base.endswith("/v1"):
|
|
308
|
+
_base = _base[:-3]
|
|
309
|
+
return _base
|
|
310
|
+
|
|
311
|
+
def _activate_offline(self) -> None:
|
|
312
|
+
_offline = load_offline_license(
|
|
313
|
+
path=self.config.offline_license_path, # type: ignore[arg-type]
|
|
314
|
+
license_key=self.config.license_key,
|
|
315
|
+
public_key_hex=self.config.public_key,
|
|
316
|
+
)
|
|
317
|
+
self._offline_license = _offline
|
|
318
|
+
self._license_id = _offline.license_id or None
|
|
319
|
+
_keys = _extract_model_keys(_offline.metadata)
|
|
320
|
+
if not _keys:
|
|
321
|
+
raise KeyUnavailableError(
|
|
322
|
+
detail=(
|
|
323
|
+
"offline license metadata contains no model keys "
|
|
324
|
+
"(expected 'modelKey' or 'models' field)"
|
|
325
|
+
),
|
|
326
|
+
)
|
|
327
|
+
self._key_provider.prime(_keys)
|
|
328
|
+
logger.info(
|
|
329
|
+
f"activated in offline mode license_id={self._license_id} keys={len(_keys)}"
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
def _activate_online(self) -> None:
|
|
333
|
+
_result = _keygen.validate_key(
|
|
334
|
+
session=self._session,
|
|
335
|
+
api_url=self._api_url,
|
|
336
|
+
account_id=self.config.account_id,
|
|
337
|
+
license_key=self.config.license_key,
|
|
338
|
+
fingerprint=self._fingerprint,
|
|
339
|
+
)
|
|
340
|
+
self._license_id = _result.license_id
|
|
341
|
+
|
|
342
|
+
if _result.code in _TERMINAL_CODES:
|
|
343
|
+
raise _TERMINAL_CODES[_result.code](detail=_result.detail or _result.code)
|
|
344
|
+
|
|
345
|
+
if _result.valid:
|
|
346
|
+
if _result.license_id is None:
|
|
347
|
+
raise ActivationError(detail="validate succeeded but returned no license_id")
|
|
348
|
+
_existing = _keygen.find_machine_id_for_fingerprint(
|
|
349
|
+
session=self._session,
|
|
350
|
+
api_url=self._api_url,
|
|
351
|
+
account_id=self.config.account_id,
|
|
352
|
+
license_key=self.config.license_key,
|
|
353
|
+
license_id=_result.license_id,
|
|
354
|
+
fingerprint=self._fingerprint,
|
|
355
|
+
)
|
|
356
|
+
if _existing is None:
|
|
357
|
+
raise ActivationError(
|
|
358
|
+
detail="license is valid but no machine matches this fingerprint",
|
|
359
|
+
code="MACHINE_LOOKUP_FAILED",
|
|
360
|
+
)
|
|
361
|
+
self._machine_id = _existing
|
|
362
|
+
elif _result.code in _ACTIVATION_NEEDED_CODES:
|
|
363
|
+
if _result.license_id is None:
|
|
364
|
+
raise ActivationError(
|
|
365
|
+
detail="validate returned no license_id for activation"
|
|
366
|
+
)
|
|
367
|
+
self._machine_id = _keygen.activate_machine(
|
|
368
|
+
session=self._session,
|
|
369
|
+
api_url=self._api_url,
|
|
370
|
+
account_id=self.config.account_id,
|
|
371
|
+
license_key=self.config.license_key,
|
|
372
|
+
license_id=_result.license_id,
|
|
373
|
+
fingerprint=self._fingerprint,
|
|
374
|
+
)
|
|
375
|
+
else:
|
|
376
|
+
raise ValidationError(
|
|
377
|
+
detail=_result.detail or _result.code,
|
|
378
|
+
code=_result.code,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
_metadata = _result.attributes.get("metadata")
|
|
382
|
+
if not isinstance(_metadata, dict):
|
|
383
|
+
_metadata = {}
|
|
384
|
+
_keys = _extract_model_keys(_metadata)
|
|
385
|
+
if not _keys:
|
|
386
|
+
raise KeyUnavailableError(
|
|
387
|
+
detail=(
|
|
388
|
+
"license metadata contains no model keys "
|
|
389
|
+
"(expected 'modelKey' or 'models' field)"
|
|
390
|
+
),
|
|
391
|
+
)
|
|
392
|
+
self._key_provider.prime(_keys)
|
|
393
|
+
logger.info(
|
|
394
|
+
f"activated online license_id={self._license_id} "
|
|
395
|
+
f"machine_id={self._machine_id} keys={len(_keys)}"
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
def _ping_once(self) -> None:
|
|
399
|
+
if self._machine_id is None:
|
|
400
|
+
return
|
|
401
|
+
_keygen.ping_heartbeat(
|
|
402
|
+
session=self._session,
|
|
403
|
+
api_url=self._api_url,
|
|
404
|
+
account_id=self.config.account_id,
|
|
405
|
+
license_key=self.config.license_key,
|
|
406
|
+
machine_id=self._machine_id,
|
|
407
|
+
)
|
|
408
|
+
# INTERNAL
|
|
409
|
+
|
|
410
|
+
# METHOD OVERRIDING
|
|
411
|
+
def __enter__(self) -> SecureClient:
|
|
412
|
+
self.activate()
|
|
413
|
+
return self
|
|
414
|
+
|
|
415
|
+
def __exit__(
|
|
416
|
+
self,
|
|
417
|
+
exc_type: type[BaseException] | None,
|
|
418
|
+
exc: BaseException | None,
|
|
419
|
+
tb: TracebackType | None,
|
|
420
|
+
) -> None:
|
|
421
|
+
try:
|
|
422
|
+
self.stop_heartbeat()
|
|
423
|
+
finally:
|
|
424
|
+
if self.config.deactivate_on_exit:
|
|
425
|
+
self.deactivate()
|
|
426
|
+
|
|
427
|
+
def __str__(self) -> str:
|
|
428
|
+
_self_dict = utils.clean_obj_dict(self.__dict__, self.__class__.__name__)
|
|
429
|
+
_self_dict.pop("_session", None)
|
|
430
|
+
return f"{self.__class__.__name__}: {pprint.pformat(_self_dict)}"
|
|
431
|
+
|
|
432
|
+
def __repr__(self) -> str:
|
|
433
|
+
return utils.obj_to_repr(self)
|
|
434
|
+
# METHOD OVERRIDING
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _extract_model_keys(metadata: dict[str, Any]) -> dict[str | None, bytes]:
|
|
438
|
+
"""Pull model keys out of a license-metadata blob.
|
|
439
|
+
|
|
440
|
+
Supported shapes::
|
|
441
|
+
|
|
442
|
+
{"modelKey": "<64 hex chars>"} -> single default key
|
|
443
|
+
{"models": {"<id>": "<hex>", "<id2>": "<hex2>"}} -> multi-model
|
|
444
|
+
|
|
445
|
+
Both can coexist; the default key is stored under None. Hex strings are
|
|
446
|
+
64 characters (32 bytes / AES-256).
|
|
447
|
+
"""
|
|
448
|
+
|
|
449
|
+
_out: dict[str | None, bytes] = {}
|
|
450
|
+
_default = metadata.get("modelKey")
|
|
451
|
+
if isinstance(_default, str):
|
|
452
|
+
_out[None] = _hex_to_aes_key(_default, where="metadata.modelKey")
|
|
453
|
+
|
|
454
|
+
_models = metadata.get("models")
|
|
455
|
+
if isinstance(_models, dict):
|
|
456
|
+
for _mid, _hex_key in _models.items():
|
|
457
|
+
if not isinstance(_mid, str) or not isinstance(_hex_key, str):
|
|
458
|
+
continue
|
|
459
|
+
_out[_mid] = _hex_to_aes_key(_hex_key, where=f"metadata.models[{_mid!r}]")
|
|
460
|
+
return _out
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _hex_to_aes_key(hex_str: str, *, where: str) -> bytes:
|
|
464
|
+
try:
|
|
465
|
+
_raw = bytes.fromhex(hex_str)
|
|
466
|
+
except ValueError as _e:
|
|
467
|
+
raise KeyUnavailableError(
|
|
468
|
+
detail=f"{where} is not valid hex: {_e}",
|
|
469
|
+
code="INVALID_KEY_HEX",
|
|
470
|
+
) from _e
|
|
471
|
+
if len(_raw) != 32:
|
|
472
|
+
raise KeyUnavailableError(
|
|
473
|
+
detail=f"{where} must decode to 32 bytes (AES-256), got {len(_raw)}",
|
|
474
|
+
code="INVALID_KEY_LENGTH",
|
|
475
|
+
)
|
|
476
|
+
return _raw
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
__all__ = ["SecureClient"]
|