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.
@@ -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
+ ]
@@ -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"]