licentivo-sdk 0.10.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- licentivo_sdk-0.10.0/LICENSE +21 -0
- licentivo_sdk-0.10.0/NOTICE +17 -0
- licentivo_sdk-0.10.0/PKG-INFO +109 -0
- licentivo_sdk-0.10.0/README.md +73 -0
- licentivo_sdk-0.10.0/licentivo.py +430 -0
- licentivo_sdk-0.10.0/licentivo_sdk.egg-info/PKG-INFO +109 -0
- licentivo_sdk-0.10.0/licentivo_sdk.egg-info/SOURCES.txt +11 -0
- licentivo_sdk-0.10.0/licentivo_sdk.egg-info/dependency_links.txt +1 -0
- licentivo_sdk-0.10.0/licentivo_sdk.egg-info/requires.txt +1 -0
- licentivo_sdk-0.10.0/licentivo_sdk.egg-info/top_level.txt +2 -0
- licentivo_sdk-0.10.0/licentivo_tasks.py +110 -0
- licentivo_sdk-0.10.0/pyproject.toml +21 -0
- licentivo_sdk-0.10.0/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 spf86 and Licentivo contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Licentivo client SDKs
|
|
2
|
+
Copyright (c) 2026 spf86 and Licentivo contributors
|
|
3
|
+
Project: https://github.com/spf86/licentivo-sdk
|
|
4
|
+
|
|
5
|
+
The MIT license applies to the Licentivo client SDK code, examples and
|
|
6
|
+
installation tools. The licensing server and administration application are
|
|
7
|
+
separate software and are not included in this SDK distribution.
|
|
8
|
+
|
|
9
|
+
Bundled third-party source retains its original license:
|
|
10
|
+
- cJSON: see sdk/c/vendor/cJSON-LICENSE (or c/vendor/cJSON-LICENSE).
|
|
11
|
+
- Monocypher and its Ed25519 extension: see
|
|
12
|
+
sdk/c/vendor/Monocypher-LICENCE.md (or c/vendor/Monocypher-LICENCE.md).
|
|
13
|
+
Native packages include these files in the licenses directory.
|
|
14
|
+
|
|
15
|
+
External dependencies such as libcurl, OpenSSL, cryptography, undici and
|
|
16
|
+
BouncyCastle retain their own licenses. Their installation is handled by
|
|
17
|
+
the corresponding package manager or the target system.
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: licentivo-sdk
|
|
3
|
+
Version: 0.10.0
|
|
4
|
+
Summary: Licentivo software licensing client
|
|
5
|
+
License: MIT License
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2026 spf86 and Licentivo contributors
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
in the Software without restriction, including without limitation the rights
|
|
12
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
furnished to do so, subject to the following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be included in all
|
|
17
|
+
copies or substantial portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
23
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
SOFTWARE.
|
|
26
|
+
|
|
27
|
+
Project-URL: Homepage, https://github.com/spf86/licentivo-sdk
|
|
28
|
+
Project-URL: Documentation, https://www.licentivo.com/en/docs.html
|
|
29
|
+
Project-URL: Issues, https://github.com/spf86/licentivo-sdk/issues
|
|
30
|
+
Requires-Python: >=3.10
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
License-File: LICENSE
|
|
33
|
+
License-File: NOTICE
|
|
34
|
+
Requires-Dist: cryptography<49,>=44.0.1
|
|
35
|
+
Dynamic: license-file
|
|
36
|
+
|
|
37
|
+
# Licentivo Python SDK
|
|
38
|
+
|
|
39
|
+
Add software license activation and protected features to a Python application without implementing the licensing HTTP protocol yourself.
|
|
40
|
+
|
|
41
|
+
## Features
|
|
42
|
+
|
|
43
|
+
- Activates a customer license, derives device identity, stores a private device credential and restores it after restart.
|
|
44
|
+
- Verifies signed grants with pinned public keys and checks license/feature status before running your business callback.
|
|
45
|
+
- Schedules policy heartbeats, supports permitted offline grants and returns floating seats on close.
|
|
46
|
+
- Handles metered-feature reservation, commit/cancel and recovery of pending confirmations using a stable job ID.
|
|
47
|
+
- Provides explicit refresh/deactivation, offline activation file exchange and structured error details.
|
|
48
|
+
|
|
49
|
+
Your application supplies the activation interface, business function and durable business results. The SDK handles the authorization workflow; management API keys stay on your server.
|
|
50
|
+
|
|
51
|
+
## Installation
|
|
52
|
+
|
|
53
|
+
Use Python 3.10+ and a virtual environment. Install the pinned release from PyPI:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
python -m pip install "licentivo-sdk==0.10.0"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
For an offline release, install local sources with `python -m pip install ./sdk/python`,
|
|
60
|
+
or a downloaded wheel with `python -m pip install ./licentivo_sdk-0.10.0-py3-none-any.whl`.
|
|
61
|
+
The [website installation guide](https://www.licentivo.com/en/docs.html#sdk) shows current release availability and the common installer.
|
|
62
|
+
For the full starter, see QUICKSTART.md at the configured ZIP root and run `start.ps1` / `start.sh`.
|
|
63
|
+
Keep `product.json` in application resources. Read the customer's key from your
|
|
64
|
+
activation UI; after activation pass an empty string to restore the credential.
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from licentivo import Client
|
|
68
|
+
from pathlib import Path
|
|
69
|
+
|
|
70
|
+
with Client.open_with_license('product.json', customer_key, on_error=print) as client:
|
|
71
|
+
client.run_feature('export', lambda: Path('report.txt').write_text('Report\n'))
|
|
72
|
+
# In your app, keep this context open until the application exits.
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
For a configured metered feature, pass your business callback to the SDK:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
client.run_metered_feature('export', 1, persistent_job_id, export_report)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The SDK reserves, runs, confirms/cancels and stores a private journal. Retain your
|
|
82
|
+
job ID and durable business result. Repeating this call after a pending commit,
|
|
83
|
+
including after restart, retries only confirmation and never repeats business.
|
|
84
|
+
If a process crashes during the callback, inspect its result and call
|
|
85
|
+
`resolve_metered_feature(job_id, succeeded)`; the SDK does not guess or rerun it.
|
|
86
|
+
One workflow runs per client; different processes need different job IDs.
|
|
87
|
+
The lower-level reserve/commit/cancel methods remain available for custom flows.
|
|
88
|
+
The counter requires an online connection. `consume()` immediately charges
|
|
89
|
+
usage and is for operations that cannot subsequently fail.
|
|
90
|
+
|
|
91
|
+
`offline_request()` returns a dictionary to save as JSON;
|
|
92
|
+
`import_offline(request_dict, response_dict)` verifies an offline activation.
|
|
93
|
+
Closing a floating client releases its seat; device bindings are kept.
|
|
94
|
+
|
|
95
|
+
Accept an ordinary `lv_lic_...` key or a replacement computer's `lv_tmp_...`
|
|
96
|
+
code through your activation UI. After activation, the SDK saves a machine-scoped
|
|
97
|
+
credential beside `cache_path`. Later starts can use an empty `license_key`
|
|
98
|
+
with the same product, trusted keys and cache path. Keep the credential in the
|
|
99
|
+
user's application data directory; never ship it with the software.
|
|
100
|
+
|
|
101
|
+
With a valid device-bound offline cache, startup restores access immediately
|
|
102
|
+
and refreshes online in the background. Online-only and floating licenses wait
|
|
103
|
+
for the server. `feature_status('export')` explains a denial without a network
|
|
104
|
+
request. Call `close()` on exit. Python's synchronous HTTP transport may finish
|
|
105
|
+
an in-flight request after close; its timeout bounds that work.
|
|
106
|
+
|
|
107
|
+
Run `python desktop.py product.json` for an activation and report-export window.
|
|
108
|
+
It retains an export journal for commit recovery. An interrupted output still
|
|
109
|
+
requires your application to check whether the business operation completed.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Licentivo Python SDK
|
|
2
|
+
|
|
3
|
+
Add software license activation and protected features to a Python application without implementing the licensing HTTP protocol yourself.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Activates a customer license, derives device identity, stores a private device credential and restores it after restart.
|
|
8
|
+
- Verifies signed grants with pinned public keys and checks license/feature status before running your business callback.
|
|
9
|
+
- Schedules policy heartbeats, supports permitted offline grants and returns floating seats on close.
|
|
10
|
+
- Handles metered-feature reservation, commit/cancel and recovery of pending confirmations using a stable job ID.
|
|
11
|
+
- Provides explicit refresh/deactivation, offline activation file exchange and structured error details.
|
|
12
|
+
|
|
13
|
+
Your application supplies the activation interface, business function and durable business results. The SDK handles the authorization workflow; management API keys stay on your server.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
Use Python 3.10+ and a virtual environment. Install the pinned release from PyPI:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
python -m pip install "licentivo-sdk==0.10.0"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
For an offline release, install local sources with `python -m pip install ./sdk/python`,
|
|
24
|
+
or a downloaded wheel with `python -m pip install ./licentivo_sdk-0.10.0-py3-none-any.whl`.
|
|
25
|
+
The [website installation guide](https://www.licentivo.com/en/docs.html#sdk) shows current release availability and the common installer.
|
|
26
|
+
For the full starter, see QUICKSTART.md at the configured ZIP root and run `start.ps1` / `start.sh`.
|
|
27
|
+
Keep `product.json` in application resources. Read the customer's key from your
|
|
28
|
+
activation UI; after activation pass an empty string to restore the credential.
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from licentivo import Client
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
with Client.open_with_license('product.json', customer_key, on_error=print) as client:
|
|
35
|
+
client.run_feature('export', lambda: Path('report.txt').write_text('Report\n'))
|
|
36
|
+
# In your app, keep this context open until the application exits.
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
For a configured metered feature, pass your business callback to the SDK:
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
client.run_metered_feature('export', 1, persistent_job_id, export_report)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The SDK reserves, runs, confirms/cancels and stores a private journal. Retain your
|
|
46
|
+
job ID and durable business result. Repeating this call after a pending commit,
|
|
47
|
+
including after restart, retries only confirmation and never repeats business.
|
|
48
|
+
If a process crashes during the callback, inspect its result and call
|
|
49
|
+
`resolve_metered_feature(job_id, succeeded)`; the SDK does not guess or rerun it.
|
|
50
|
+
One workflow runs per client; different processes need different job IDs.
|
|
51
|
+
The lower-level reserve/commit/cancel methods remain available for custom flows.
|
|
52
|
+
The counter requires an online connection. `consume()` immediately charges
|
|
53
|
+
usage and is for operations that cannot subsequently fail.
|
|
54
|
+
|
|
55
|
+
`offline_request()` returns a dictionary to save as JSON;
|
|
56
|
+
`import_offline(request_dict, response_dict)` verifies an offline activation.
|
|
57
|
+
Closing a floating client releases its seat; device bindings are kept.
|
|
58
|
+
|
|
59
|
+
Accept an ordinary `lv_lic_...` key or a replacement computer's `lv_tmp_...`
|
|
60
|
+
code through your activation UI. After activation, the SDK saves a machine-scoped
|
|
61
|
+
credential beside `cache_path`. Later starts can use an empty `license_key`
|
|
62
|
+
with the same product, trusted keys and cache path. Keep the credential in the
|
|
63
|
+
user's application data directory; never ship it with the software.
|
|
64
|
+
|
|
65
|
+
With a valid device-bound offline cache, startup restores access immediately
|
|
66
|
+
and refreshes online in the background. Online-only and floating licenses wait
|
|
67
|
+
for the server. `feature_status('export')` explains a denial without a network
|
|
68
|
+
request. Call `close()` on exit. Python's synchronous HTTP transport may finish
|
|
69
|
+
an in-flight request after close; its timeout bounds that work.
|
|
70
|
+
|
|
71
|
+
Run `python desktop.py product.json` for an activation and report-export window.
|
|
72
|
+
It retains an export journal for commit recovery. An interrupted output still
|
|
73
|
+
requires your application to check whether the business operation completed.
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
"""Licentivo runtime SDK, Python 3.10+. Pin public keys; never put a vendor API key here."""
|
|
2
|
+
import base64
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import licentivo_tasks
|
|
6
|
+
import os
|
|
7
|
+
import platform
|
|
8
|
+
import subprocess
|
|
9
|
+
import random
|
|
10
|
+
import re
|
|
11
|
+
import tempfile
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.parse
|
|
16
|
+
import urllib.request
|
|
17
|
+
import uuid
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
20
|
+
|
|
21
|
+
LIMIT = 16384
|
|
22
|
+
FIELDS = {'version','key_id','tenant_id','product_id','license_id','device_id','activation_id','mode','features','max_devices','issued_at','expires_at'}
|
|
23
|
+
UUID = re.compile(r'^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$')
|
|
24
|
+
|
|
25
|
+
def _pairs(pairs):
|
|
26
|
+
result = {}
|
|
27
|
+
for key,value in pairs:
|
|
28
|
+
if key in result: raise ValueError('duplicate JSON field')
|
|
29
|
+
result[key] = value
|
|
30
|
+
return result
|
|
31
|
+
|
|
32
|
+
def loads(data):
|
|
33
|
+
if len(data) > LIMIT: raise ValueError('response size limit')
|
|
34
|
+
return json.loads(data, object_pairs_hook=_pairs, parse_constant=lambda x: (_ for _ in ()).throw(ValueError('invalid number')))
|
|
35
|
+
|
|
36
|
+
def decode(s):
|
|
37
|
+
if not isinstance(s,str) or not re.fullmatch(r'[A-Za-z0-9_-]+',s): raise ValueError('invalid base64url')
|
|
38
|
+
b = base64.urlsafe_b64decode(s + '='*((-len(s))%4))
|
|
39
|
+
if base64.urlsafe_b64encode(b).decode().rstrip('=') != s: raise ValueError('noncanonical base64url')
|
|
40
|
+
return b
|
|
41
|
+
|
|
42
|
+
def system_device_id(product):
|
|
43
|
+
system=platform.system(); identity=''
|
|
44
|
+
if system=='Windows':
|
|
45
|
+
import winreg
|
|
46
|
+
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE,r'SOFTWARE\Microsoft\Cryptography',0,winreg.KEY_READ|winreg.KEY_WOW64_64KEY) as key:
|
|
47
|
+
identity=winreg.QueryValueEx(key,'MachineGuid')[0]
|
|
48
|
+
elif system=='Linux':
|
|
49
|
+
for p in ['/etc/machine-id','/var/lib/dbus/machine-id']:
|
|
50
|
+
try: identity=Path(p).read_text().strip()
|
|
51
|
+
except OSError: continue
|
|
52
|
+
if identity: break
|
|
53
|
+
elif system=='Darwin':
|
|
54
|
+
result=subprocess.check_output(['/usr/sbin/ioreg','-rd1','-c','IOPlatformExpertDevice'],timeout=3,text=True)
|
|
55
|
+
match=re.search(r'"IOPlatformUUID"\s*=\s*"([0-9A-Fa-f-]+)"',result)
|
|
56
|
+
if match: identity=match[1]
|
|
57
|
+
identity=identity.strip().lower()
|
|
58
|
+
if not re.fullmatch(r'(?:[0-9a-f]{32}|[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12})',identity) or not identity.replace('-','').strip('0'):
|
|
59
|
+
raise ValueError('OS machine identity unavailable; cannot bind license')
|
|
60
|
+
return hashlib.sha256(('LicenovaDevice/v2\n'+product.lower()+'\n'+identity).encode()).hexdigest()
|
|
61
|
+
|
|
62
|
+
def machine_profile(product):
|
|
63
|
+
system=system_device_id(product); hardware='';description='';os_name=platform.system()
|
|
64
|
+
try:
|
|
65
|
+
if os_name=='Windows':
|
|
66
|
+
result=subprocess.check_output(['powershell.exe','-NoProfile','-NonInteractive','-Command','(Get-CimInstance Win32_ComputerSystemProduct).UUID; $v=Get-CimInstance Win32_ComputerSystem; $v.Manufacturer; $v.Model'],timeout=5,text=True,creationflags=subprocess.CREATE_NO_WINDOW)
|
|
67
|
+
hardware=result.strip().splitlines()[0];description=result
|
|
68
|
+
elif os_name=='Linux':
|
|
69
|
+
try: hardware=Path('/sys/class/dmi/id/product_uuid').read_text()
|
|
70
|
+
except OSError: pass
|
|
71
|
+
for path in ['/sys/class/dmi/id/sys_vendor','/sys/class/dmi/id/product_name']:
|
|
72
|
+
try: description+=' '+Path(path).read_text()
|
|
73
|
+
except OSError: pass
|
|
74
|
+
if Path('/.dockerenv').exists(): description+=' virtual machine'
|
|
75
|
+
elif os_name=='Darwin':
|
|
76
|
+
result=subprocess.check_output(['/usr/sbin/ioreg','-rd1','-c','IOPlatformExpertDevice'],timeout=5,text=True)
|
|
77
|
+
hardware=re.search(r'"IOPlatformUUID"\s*=\s*"([0-9A-Fa-f-]+)"',result)[1];description=result
|
|
78
|
+
except (OSError,subprocess.SubprocessError,IndexError,TypeError): pass
|
|
79
|
+
hardware=hardware.strip().lower();hw=''
|
|
80
|
+
if UUID.fullmatch(hardware) and hardware.replace('-','').strip('0f'):
|
|
81
|
+
hw=hashlib.sha256(('LicenovaHardware/v1\n'+product.lower()+'\n'+hardware).encode()).hexdigest()
|
|
82
|
+
profile={'version':3,'system_id':system,'hardware_id':hw,'virtual_machine':bool(re.search(r'vmware|virtualbox|virtual machine|kvm|qemu|xen|parallels',description,re.I)),'platform':{'Windows':'windows','Linux':'linux','Darwin':'darwin'}[os_name]}
|
|
83
|
+
return profile,hashlib.sha256(('LicenovaDevice/v3\n'+product.lower()+'\n'+system+'\n'+hw).encode()).hexdigest()
|
|
84
|
+
def machine_device_id(product): return machine_profile(product)[1]
|
|
85
|
+
|
|
86
|
+
def extended_claims(c):
|
|
87
|
+
if 'offline_request_id' in c and (not isinstance(c['offline_request_id'],str) or not UUID.fullmatch(c['offline_request_id'])): raise ValueError('invalid offline request ID')
|
|
88
|
+
if 'capabilities' not in c: return
|
|
89
|
+
x=c['capabilities'];allowed={'license_model','session_id','session_expires_at','app_version','version_released_at','maintenance_until'}
|
|
90
|
+
if not isinstance(x,dict) or set(x)-allowed or x.get('license_model') not in ['node_locked','floating']: raise ValueError('invalid license capabilities')
|
|
91
|
+
for field in ['session_expires_at','version_released_at','maintenance_until']:
|
|
92
|
+
if field in x and (type(x[field]) is not int or not 0<=x[field]<=253402300799): raise ValueError('invalid entitlement date')
|
|
93
|
+
if 'app_version' in x and (not isinstance(x['app_version'],str) or not re.fullmatch(r'(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})',x['app_version'])): raise ValueError('invalid application version')
|
|
94
|
+
if x['license_model']=='floating' and (not isinstance(x.get('session_id'),str) or not UUID.fullmatch(x['session_id']) or x.get('session_expires_at',0)<=c['issued_at'] or c['expires_at']>x['session_expires_at'] or c.get('runtime_policy',{}).get('heartbeat_seconds',0)==0 or policy_mode(c)=='permanent'): raise ValueError('invalid floating session')
|
|
95
|
+
if x.get('version_released_at',0)>c['issued_at'] or x.get('maintenance_until',0)>0 and (not x.get('app_version') or x.get('version_released_at',0)==0 or x['version_released_at']>x['maintenance_until']): raise ValueError('invalid maintenance entitlement')
|
|
96
|
+
|
|
97
|
+
def policy_mode(c): return c.get('runtime_policy',{}).get('offline_mode','limited')
|
|
98
|
+
def max_span(c):
|
|
99
|
+
if 'runtime_policy' not in c: return 86400
|
|
100
|
+
p=c['runtime_policy']
|
|
101
|
+
if not isinstance(p,dict) or set(p)!={'offline_mode','heartbeat_seconds','offline_seconds'} or any(type(p[f]) is not int for f in ['heartbeat_seconds','offline_seconds']): raise ValueError('invalid runtime policy')
|
|
102
|
+
mode,h,o=p['offline_mode'],p['heartbeat_seconds'],p['offline_seconds']
|
|
103
|
+
if h!=0 and not 10<=h<=86400: raise ValueError('invalid heartbeat interval')
|
|
104
|
+
if mode=='permanent' and o==0: return 253402300799-c['issued_at']
|
|
105
|
+
if mode=='none' and o==0: return 10 if h==0 else h+(h+9)//10+30
|
|
106
|
+
if mode=='limited' and 1<=o<=31536000: return o
|
|
107
|
+
raise ValueError('invalid runtime policy')
|
|
108
|
+
|
|
109
|
+
def verify(envelope, keys, product, device, now=None):
|
|
110
|
+
now = int(time.time()) if now is None else now
|
|
111
|
+
if set(envelope) != {'version','key_id','payload','signature'} or type(envelope['version']) is not int or envelope['version'] != 1: raise ValueError('invalid envelope')
|
|
112
|
+
raw = decode(keys[envelope['key_id']]); payload = decode(envelope['payload']); signature = decode(envelope['signature'])
|
|
113
|
+
if len(raw)!=32 or len(signature)!=64 or len(payload)>LIMIT: raise ValueError('invalid key/signature length')
|
|
114
|
+
Ed25519PublicKey.from_public_bytes(raw).verify(signature,b'LicenovaLease/v1\n'+payload)
|
|
115
|
+
c = loads(payload)
|
|
116
|
+
if set(c) != FIELDS | (set(c)&{'runtime_policy','capabilities','offline_request_id'}): raise ValueError('invalid claims fields')
|
|
117
|
+
if type(c['version']) is not int or c['version']!=1 or c['key_id']!=envelope['key_id'] or c['product_id']!=product or c['device_id']!=device: raise ValueError('lease context mismatch')
|
|
118
|
+
if any(not isinstance(c[f],str) or not UUID.fullmatch(c[f]) for f in ['tenant_id','product_id','license_id','activation_id']): raise ValueError('invalid identifiers')
|
|
119
|
+
if not re.fullmatch(r'[0-9a-f]{64}',device) or c['mode'] not in ['perpetual','timed','trial']: raise ValueError('invalid mode/device')
|
|
120
|
+
if any(type(c[f]) is not int for f in ['max_devices','issued_at','expires_at']): raise ValueError('invalid integer claim')
|
|
121
|
+
if not 1<=c['max_devices']<=10000 or c['issued_at']<=0 or not 0<c['expires_at']-c['issued_at']<=max_span(c) or c['expires_at']>253402300799 or c['issued_at']>now+300 or now>=c['expires_at']: raise ValueError('expired lease/clock')
|
|
122
|
+
fs=c['features']
|
|
123
|
+
if not isinstance(fs,list) or len(fs)>32 or any(not isinstance(f,str) or not re.fullmatch(r'[A-Za-z0-9_.-]{1,64}',f) for f in fs) or sorted(set(fs))!=fs: raise ValueError('invalid features')
|
|
124
|
+
extended_claims(c)
|
|
125
|
+
return c
|
|
126
|
+
|
|
127
|
+
def device_id(product, installation_id):
|
|
128
|
+
return hashlib.sha256(('licentivo-device-v1\n'+product+'\n'+installation_id.strip().lower()).encode()).hexdigest()
|
|
129
|
+
|
|
130
|
+
class APIError(Exception):
|
|
131
|
+
def __init__(self, status, code, request_id='', retry_after_seconds=0):
|
|
132
|
+
self.status=status; self.code=code; self.request_id=request_id; self.retryable=status==429 or status>=500; self.retry_after_seconds=retry_after_seconds; super().__init__(f'Licentivo {code} ({status})')
|
|
133
|
+
|
|
134
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
135
|
+
def redirect_request(self,*args,**kwargs): return None
|
|
136
|
+
|
|
137
|
+
class Client:
|
|
138
|
+
def __init__(self, config):
|
|
139
|
+
self.feature_task_lock = threading.Lock()
|
|
140
|
+
self.config=dict(config); self.config.setdefault('license_key',''); self.config['base_url']=self.config['base_url'].rstrip('/');
|
|
141
|
+
if not self.config.get('cache_path'): self.config['cache_path']=str(Path(os.environ.get('LOCALAPPDATA') or os.environ.get('XDG_CACHE_HOME') or str(Path.home()/'.cache'))/'Licentivo'/hashlib.sha256((self.config['base_url']+'\n'+self.config['product_id']).encode()).hexdigest()/'lease.json')
|
|
142
|
+
self.profile,self.config['device_id']=machine_profile(config['product_id']); self.session_id=str(uuid.uuid4()); config=self.config; u=urllib.parse.urlsplit(config['base_url'])
|
|
143
|
+
if u.username or not u.hostname or u.query or u.fragment or u.path not in ['','/']: raise ValueError('base_url must be an origin')
|
|
144
|
+
if u.scheme!='https' and not (config.get('allow_http') is True and u.scheme=='http' and u.hostname in ['localhost','127.0.0.1','::1']): raise ValueError('HTTPS required')
|
|
145
|
+
if not re.fullmatch(r'[0-9a-f]{64}',config['device_id']) or config['license_key'] and not re.fullmatch(r'lv_(lic|tmp)_[A-Za-z0-9_-]{43}',config['license_key']): raise ValueError('invalid device/license')
|
|
146
|
+
if not config['trusted_keys'] or any(len(decode(k))!=32 for k in config['trusted_keys'].values()): raise ValueError('pin a public key')
|
|
147
|
+
self.lock=threading.RLock(); self.state=None; self.interval=3600; self.wall=0; self.tick=time.monotonic(); self.stop=threading.Event(); self.thread=None; self.refresh_thread=None; self.closed=False
|
|
148
|
+
self.timeout=config.get('timeout_seconds',10)
|
|
149
|
+
if not isinstance(self.timeout,(int,float)) or not 1<=self.timeout<=120: raise ValueError('timeout_seconds must be 1–120')
|
|
150
|
+
handlers=[_NoRedirect()]
|
|
151
|
+
if config.get('proxy_url'): handlers.append(urllib.request.ProxyHandler({'http':config['proxy_url'],'https':config['proxy_url']}))
|
|
152
|
+
self.opener=urllib.request.build_opener(*handlers);self.on_status_changed=None;self.last_error=None;self.online=False;self.retry_at=0;self.snapshot=(None,0,self.tick)
|
|
153
|
+
self.auth=None;self.auth_dirty=False;self._load_credential()
|
|
154
|
+
if not config['license_key'] and not self.auth: raise ValueError('enter a license key or temporary activation code')
|
|
155
|
+
try:
|
|
156
|
+
p=Path(config.get('cache_path','')); s=loads(p.read_bytes()) if p.is_file() and p.stat().st_size<=LIMIT else None
|
|
157
|
+
if s and s['version']==1 and s['license_hash']==self._hash() and time.time()+300>=s['observed_at']:
|
|
158
|
+
c=verify(s['lease'],config['trusted_keys'],config['product_id'],config['device_id'])
|
|
159
|
+
if policy_mode(c)!='none' and c.get('capabilities',{}).get('license_model')!='floating' and c.get('capabilities',{}).get('app_version','')==config.get('app_version',''):
|
|
160
|
+
self.state=s;self.wall=s['observed_at'];self.interval=c.get('runtime_policy',{}).get('heartbeat_seconds',3600)
|
|
161
|
+
except Exception: pass # A corrupt/expired cache never grants access.
|
|
162
|
+
self._publish(False)
|
|
163
|
+
|
|
164
|
+
def _hash(self): return self.auth['source_hash'] if not self.config['license_key'] and self.auth else hashlib.sha256(self.config['license_key'].encode()).hexdigest()
|
|
165
|
+
def _temporary(self): return bool(self.config['license_key']) or self.auth is not None
|
|
166
|
+
def _runtime_key(self): return self.auth.get('device_key') or self.config['license_key'] if self.auth else self.config['license_key']
|
|
167
|
+
def _load_credential(self):
|
|
168
|
+
if not self.config.get('cache_path'): return
|
|
169
|
+
try:
|
|
170
|
+
path=Path(self.config['cache_path']+'.credential.json')
|
|
171
|
+
if path.stat().st_size>LIMIT: return
|
|
172
|
+
s=loads(path.read_bytes())
|
|
173
|
+
if s.get('version')!=1 or s.get('origin')!=self.config['base_url'] or not re.fullmatch(r'[0-9a-f]{64}',s.get('source_hash','')) or self.config['license_key'] and s.get('source_hash')!=self._hash() or any(s.get(k)!=self.config[k] for k in ['product_id','device_id']) or not UUID.fullmatch(s.get('request_id','')) or not UUID.fullmatch(s.get('session_id','')) or s.get('device_key') and not re.fullmatch(r'lv_dev_[A-Za-z0-9_-]{43}',s['device_key']): return
|
|
174
|
+
if not self.config['license_key'] and not s.get('device_key'): return
|
|
175
|
+
self.auth=s
|
|
176
|
+
if not s.get('device_key'): self.session_id=s['session_id']
|
|
177
|
+
except (OSError,ValueError,KeyError,TypeError): pass
|
|
178
|
+
def _prepare_credential(self):
|
|
179
|
+
if not self._temporary(): return
|
|
180
|
+
if not self.config.get('cache_path'): raise ValueError('cache_path required to retain a transferred license')
|
|
181
|
+
if not self.auth:
|
|
182
|
+
self.auth={'version':1,'origin':self.config['base_url'],'source_hash':self._hash(),'product_id':self.config['product_id'],'device_id':self.config['device_id'],'device_key':'','request_id':str(uuid.uuid4()),'session_id':self.session_id}
|
|
183
|
+
self.auth_dirty=True
|
|
184
|
+
if self.auth_dirty: self._save_credential()
|
|
185
|
+
def _save_credential(self):
|
|
186
|
+
p=Path(self.config['cache_path']+'.credential.json');p.parent.mkdir(parents=True,exist_ok=True)
|
|
187
|
+
fd,tmp=tempfile.mkstemp(prefix='.credential-',dir=p.parent)
|
|
188
|
+
try:
|
|
189
|
+
os.chmod(tmp,0o600)
|
|
190
|
+
with os.fdopen(fd,'w',encoding='utf-8') as f: json.dump(self.auth,f,separators=(',',':'))
|
|
191
|
+
os.replace(tmp,p);self.auth_dirty=False
|
|
192
|
+
finally:
|
|
193
|
+
if os.path.exists(tmp): os.unlink(tmp)
|
|
194
|
+
def _accept_credential(self,result):
|
|
195
|
+
if not self._temporary() or self.auth and self.auth.get('device_key'): return
|
|
196
|
+
if not re.fullmatch(r'lv_dev_[A-Za-z0-9_-]{43}',result.get('device_key','')):
|
|
197
|
+
if not self.config['license_key'].startswith('lv_tmp_'): return
|
|
198
|
+
raise ValueError('server omitted device credential')
|
|
199
|
+
self._prepare_credential();self.auth['device_key']=result['device_key'];self.auth_dirty=True;self._save_credential()
|
|
200
|
+
def _check(self):
|
|
201
|
+
if self.wall and time.time() < self.wall+(time.monotonic()-self.tick)-300: raise ValueError('clock rollback; verify online')
|
|
202
|
+
if not self.state: raise ValueError('no valid lease')
|
|
203
|
+
c=verify(self.state['lease'],self.config['trusted_keys'],self.config['product_id'],self.config['device_id']);self._context(c);return c
|
|
204
|
+
def _invalidate(self):
|
|
205
|
+
self.state=None
|
|
206
|
+
self._publish(False)
|
|
207
|
+
path=self.config.get('cache_path')
|
|
208
|
+
if path:
|
|
209
|
+
try: Path(path).unlink(missing_ok=True)
|
|
210
|
+
except OSError: pass
|
|
211
|
+
def _save(self):
|
|
212
|
+
path=self.config.get('cache_path')
|
|
213
|
+
if not path: return
|
|
214
|
+
p=Path(path);p.parent.mkdir(parents=True,exist_ok=True)
|
|
215
|
+
fd,tmp=tempfile.mkstemp(prefix='.lease-',dir=p.parent)
|
|
216
|
+
try:
|
|
217
|
+
os.chmod(tmp,0o600)
|
|
218
|
+
with os.fdopen(fd,'w',encoding='utf-8') as f: json.dump(self.state,f,separators=(',',':'))
|
|
219
|
+
os.replace(tmp,p)
|
|
220
|
+
finally:
|
|
221
|
+
if os.path.exists(tmp): os.unlink(tmp)
|
|
222
|
+
def _request(self,operation,activation,operation_id=None,extra=None):
|
|
223
|
+
self._prepare_credential()
|
|
224
|
+
cfg=self.config; body=json.dumps(self._body(activation) | (extra or {})).encode()
|
|
225
|
+
idem=operation_id or str(uuid.uuid4());last=None
|
|
226
|
+
if operation=='activate' and self._temporary() and not self.auth.get('device_key') and not self.state: idem=self.auth['request_id']
|
|
227
|
+
for attempt in range(2):
|
|
228
|
+
if attempt: time.sleep(.2)
|
|
229
|
+
req=urllib.request.Request(cfg['base_url'].rstrip('/')+'/api/v1/'+operation,body,{'Content-Type':'application/json','Idempotency-Key':idem},method='POST')
|
|
230
|
+
try:
|
|
231
|
+
try: res=self.opener.open(req,timeout=self.timeout)
|
|
232
|
+
except urllib.error.HTTPError as e: res=e
|
|
233
|
+
with res:
|
|
234
|
+
status=res.code; request_headers=res.headers; b=res.read(LIMIT+1)
|
|
235
|
+
if len(b)>LIMIT: raise ValueError('oversized response')
|
|
236
|
+
try: env=loads(b)
|
|
237
|
+
except ValueError:
|
|
238
|
+
if status!=429 and status<500: raise
|
|
239
|
+
retry=request_headers.get('Retry-After','0')
|
|
240
|
+
error=APIError(status,'UNEXPECTED_RESPONSE',request_headers.get('X-Request-ID',''),min(86400,int(retry)) if retry.isdigit() else 0)
|
|
241
|
+
if status>=500: last=error;continue
|
|
242
|
+
raise error
|
|
243
|
+
if status!=200 or env.get('error'):
|
|
244
|
+
retry=int(request_headers.get('Retry-After','0')) if request_headers.get('Retry-After','0').isdigit() else 0
|
|
245
|
+
error=APIError(status,env.get('error',{}).get('code','UNEXPECTED_RESPONSE'),env.get('request_id',''),min(86400,retry))
|
|
246
|
+
if status>=500: last=error;continue
|
|
247
|
+
raise error
|
|
248
|
+
return env['data']
|
|
249
|
+
except (urllib.error.URLError,TimeoutError,OSError) as e: last=e
|
|
250
|
+
raise last
|
|
251
|
+
def _call(self,operation,force_online=False):
|
|
252
|
+
with self.lock:
|
|
253
|
+
try:
|
|
254
|
+
cached=self._check()
|
|
255
|
+
if not force_online and operation=='activate' and policy_mode(cached)=='permanent' and cached['runtime_policy']['heartbeat_seconds']==0: return cached
|
|
256
|
+
except Exception: pass
|
|
257
|
+
activation=''
|
|
258
|
+
if operation!='activate':
|
|
259
|
+
if not self.state: raise ValueError('activate first')
|
|
260
|
+
activation=loads(decode(self.state['lease']['payload']))['activation_id']
|
|
261
|
+
try: result=self._request(operation,activation)
|
|
262
|
+
except Exception as e:
|
|
263
|
+
transient=isinstance(e,(urllib.error.URLError,TimeoutError,OSError)) or isinstance(e,APIError) and e.retryable
|
|
264
|
+
if transient:
|
|
265
|
+
try:
|
|
266
|
+
c=self._check()
|
|
267
|
+
if not force_online and policy_mode(c)!='none': self._publish(False,e);return c
|
|
268
|
+
if policy_mode(c)=='none': self._invalidate()
|
|
269
|
+
except Exception: pass
|
|
270
|
+
self._publish(False,e);raise e
|
|
271
|
+
self._invalidate();self._publish(False,e);raise
|
|
272
|
+
try:
|
|
273
|
+
c=verify(result['lease'],self.config['trusted_keys'],self.config['product_id'],self.config['device_id'])
|
|
274
|
+
self._context(c)
|
|
275
|
+
if c['activation_id']!=result['activation_id']: raise ValueError('activation mismatch')
|
|
276
|
+
except Exception: self._invalidate();raise
|
|
277
|
+
self._accept_credential(result)
|
|
278
|
+
self.wall=time.time();self.tick=time.monotonic();self.state={'version':1,'license_hash':self._hash(),'lease':result['lease'],'observed_at':int(self.wall)}
|
|
279
|
+
self.interval=c.get('runtime_policy',{}).get('heartbeat_seconds',max(10,min(86400,result.get('heartbeat_seconds',3600))));self._save();self._publish(True);return c
|
|
280
|
+
def activate(self): return self._call('activate')
|
|
281
|
+
def validate(self): return self._call('validate')
|
|
282
|
+
def heartbeat(self): return self._call('heartbeat')
|
|
283
|
+
def refresh_online(self): return self._call('activate',True)
|
|
284
|
+
def check_feature(self,name):
|
|
285
|
+
return self.feature_status(name)['allowed']
|
|
286
|
+
def deactivate(self):
|
|
287
|
+
with self.lock:
|
|
288
|
+
claims=loads(decode(self.state['lease']['payload'])) if self.state else {}
|
|
289
|
+
activation=claims.get('activation_id','');floating=claims.get('capabilities',{}).get('license_model')=='floating'
|
|
290
|
+
self._invalidate()
|
|
291
|
+
if activation: self._request('deactivate',activation)
|
|
292
|
+
if not floating: Path(self.config['cache_path']+'.credential.json').unlink(missing_ok=True);self.auth=None
|
|
293
|
+
self._publish(False,APIError(0,'DEVICE_RELEASED'))
|
|
294
|
+
def start_heartbeat(self,on_error=None):
|
|
295
|
+
try:
|
|
296
|
+
if self.interval==0: return
|
|
297
|
+
except Exception: pass
|
|
298
|
+
if self.thread and self.thread.is_alive(): return
|
|
299
|
+
self.stop.clear()
|
|
300
|
+
def run():
|
|
301
|
+
while self.interval and not self.stop.wait(max(max(600,self.interval)*random.uniform(1,1.1),self.retry_at-time.time())):
|
|
302
|
+
try: self.heartbeat()
|
|
303
|
+
except Exception as e:
|
|
304
|
+
if on_error: on_error(e)
|
|
305
|
+
self.thread=threading.Thread(target=run,daemon=True);self.thread.start()
|
|
306
|
+
def close(self):
|
|
307
|
+
if self.closed: return
|
|
308
|
+
self.closed=True
|
|
309
|
+
self.stop.set()
|
|
310
|
+
for worker in [self.thread,self.refresh_thread]:
|
|
311
|
+
if worker and worker is not threading.current_thread(): worker.join(timeout=5)
|
|
312
|
+
self.thread=None
|
|
313
|
+
self.refresh_thread=None
|
|
314
|
+
try:
|
|
315
|
+
if self._check().get('capabilities',{}).get('license_model')=='floating': self.deactivate()
|
|
316
|
+
except Exception: pass
|
|
317
|
+
def _context(self,c):
|
|
318
|
+
x=c.get('capabilities')
|
|
319
|
+
if x and (x.get('app_version','')!=self.config.get('app_version','') or x['license_model']=='floating' and x.get('session_id')!=self.session_id): raise ValueError('lease belongs to another version/session')
|
|
320
|
+
def _body(self,activation=''):
|
|
321
|
+
return {k:self.config.get(k,'') for k in ['product_id','device_id','device_name','app_version']} | {'license_key':self._runtime_key(),'activation_id':activation,'session_id':self.session_id,'device_profile':self.profile}
|
|
322
|
+
def start(self,on_error=None):
|
|
323
|
+
if self.closed: raise RuntimeError('client is closed')
|
|
324
|
+
try:
|
|
325
|
+
result=self._check()
|
|
326
|
+
if policy_mode(result)=='none' or result.get('capabilities',{}).get('license_model')=='floating': raise ValueError('online startup required')
|
|
327
|
+
except Exception:
|
|
328
|
+
result=self.activate();self.start_heartbeat(on_error);return result
|
|
329
|
+
self.start_heartbeat(on_error)
|
|
330
|
+
def refresh():
|
|
331
|
+
if self.stop.is_set(): return
|
|
332
|
+
try: self.refresh_online()
|
|
333
|
+
except Exception as e:
|
|
334
|
+
if on_error and not self.stop.is_set(): on_error(e)
|
|
335
|
+
if not self.refresh_thread or not self.refresh_thread.is_alive():
|
|
336
|
+
self.refresh_thread=threading.Thread(target=refresh,daemon=True);self.refresh_thread.start()
|
|
337
|
+
return result
|
|
338
|
+
@classmethod
|
|
339
|
+
def open(cls,path,on_error=None):
|
|
340
|
+
client=cls(loads(Path(path).read_bytes()))
|
|
341
|
+
try: client.start(on_error);return client
|
|
342
|
+
except Exception: client.close();raise
|
|
343
|
+
@classmethod
|
|
344
|
+
def open_with_license(cls,path,license_key,on_error=None):
|
|
345
|
+
"""Load shared product settings and use the customer's license key."""
|
|
346
|
+
config=loads(Path(path).read_bytes())
|
|
347
|
+
config['license_key']=license_key.strip()
|
|
348
|
+
client=cls(config)
|
|
349
|
+
try: client.start(on_error);return client
|
|
350
|
+
except Exception: client.close();raise
|
|
351
|
+
def consume(self,feature,quantity,operation_id):
|
|
352
|
+
if not operation_id: raise ValueError('retain an operation_id for retries')
|
|
353
|
+
with self.lock:
|
|
354
|
+
activation=self._check()['activation_id']
|
|
355
|
+
try: return self._request('consume',activation,operation_id,{'feature':feature,'quantity':quantity})['consumption']
|
|
356
|
+
except APIError as e:
|
|
357
|
+
if e.code in ['LICENSE_REVOKED','LICENSE_SUSPENDED','LICENSE_EXPIRED','DEVICE_RELEASED','DEVICE_CREDENTIAL_REVOKED']: self._invalidate()
|
|
358
|
+
self._publish(False,e)
|
|
359
|
+
raise
|
|
360
|
+
def offline_request(self,operation='activate'):
|
|
361
|
+
with self.lock:
|
|
362
|
+
self._prepare_credential()
|
|
363
|
+
if operation not in ['activate','deactivate']: raise ValueError('invalid offline operation')
|
|
364
|
+
activation=''
|
|
365
|
+
if operation=='deactivate': activation=self._check()['activation_id'];self._invalidate()
|
|
366
|
+
return self._body(activation) | {'version':1,'request_id':str(uuid.uuid4()),'created_at':int(time.time()),'operation':operation}
|
|
367
|
+
def import_offline(self,request,response):
|
|
368
|
+
with self.lock:
|
|
369
|
+
if response.get('version')!=1 or request.get('operation')!='activate' or response.get('operation')!='activate' or response.get('request_id')!=request.get('request_id') or any(request.get(k)!=self._body().get(k) for k in ['product_id','device_id','license_key']): raise ValueError('offline context mismatch')
|
|
370
|
+
result=response['result'];c=verify(result['lease'],self.config['trusted_keys'],self.config['product_id'],self.config['device_id']);self._context(c)
|
|
371
|
+
if policy_mode(c)=='none' or c.get('offline_request_id')!=request['request_id'] or c['activation_id']!=result['activation_id']: raise ValueError('offline signature context mismatch')
|
|
372
|
+
self._accept_credential(result)
|
|
373
|
+
self.wall=time.time();self.tick=time.monotonic();self.state={'version':1,'license_hash':self._hash(),'lease':result['lease'],'observed_at':int(self.wall)};self.interval=c['runtime_policy']['heartbeat_seconds'];self._save();self._publish(False);return c
|
|
374
|
+
def _publish(self,online,error=None):
|
|
375
|
+
before=self.status();self.snapshot=(self.state,self.wall,self.tick);self.online=online;self.last_error=error
|
|
376
|
+
if isinstance(error,APIError) and error.retry_after_seconds: self.retry_at=time.time()+error.retry_after_seconds
|
|
377
|
+
after=self.status()
|
|
378
|
+
if before!=after and self.on_status_changed:
|
|
379
|
+
callback=self.on_status_changed
|
|
380
|
+
threading.Thread(target=lambda:callback(after),daemon=True).start()
|
|
381
|
+
def status(self):
|
|
382
|
+
state,wall,tick=self.snapshot
|
|
383
|
+
result={'state':'verification_required','allowed':False,'code':'','request_id':'','retryable':False,'lease_valid_until':0}
|
|
384
|
+
if self.last_error:
|
|
385
|
+
result.update(code=getattr(self.last_error,'code','NETWORK_UNAVAILABLE'),request_id=getattr(self.last_error,'request_id',''),retryable=getattr(self.last_error,'retryable',True))
|
|
386
|
+
try:
|
|
387
|
+
if wall and time.time()<wall+time.monotonic()-tick-300: raise ValueError('clock rollback')
|
|
388
|
+
claims=verify(state['lease'],self.config['trusted_keys'],self.config['product_id'],self.config['device_id']);self._context(claims)
|
|
389
|
+
result.update(state='active' if self.online else 'offline_valid',allowed=True,lease_valid_until=claims['expires_at'])
|
|
390
|
+
except Exception: pass
|
|
391
|
+
result['state']={'LICENSE_EXPIRED':'expired','LICENSE_REVOKED':'revoked','LICENSE_SUSPENDED':'revoked','DEVICE_RELEASED':'released','DEVICE_CREDENTIAL_REVOKED':'released','FEATURE_QUOTA_EXCEEDED':'quota_exhausted'}.get(result['code'],result['state'])
|
|
392
|
+
return result
|
|
393
|
+
def run_feature(self, name, action):
|
|
394
|
+
"""Run a non-metered business action only when the signed grant allows it."""
|
|
395
|
+
if not callable(action): raise TypeError('business action is required')
|
|
396
|
+
status = self.feature_status(name)
|
|
397
|
+
if not status['allowed']:
|
|
398
|
+
raise RuntimeError(f"feature denied: {status['code']} ({status['state']}); request_id={status['request_id']}")
|
|
399
|
+
return action()
|
|
400
|
+
|
|
401
|
+
def run_metered_feature(self, name, quantity, operation_id, action):
|
|
402
|
+
return licentivo_tasks.run(self, name, quantity, operation_id, action)
|
|
403
|
+
|
|
404
|
+
def resolve_metered_feature(self, operation_id, succeeded):
|
|
405
|
+
return licentivo_tasks.resolve(self, operation_id, succeeded)
|
|
406
|
+
|
|
407
|
+
def feature_status(self,name):
|
|
408
|
+
result=self.status()
|
|
409
|
+
if result['allowed']:
|
|
410
|
+
try:
|
|
411
|
+
claims=verify(self.snapshot[0]['lease'],self.config['trusted_keys'],self.config['product_id'],self.config['device_id'])
|
|
412
|
+
if name not in claims['features']: result.update(allowed=False,code='FEATURE_NOT_ENTITLED')
|
|
413
|
+
except Exception: result.update(allowed=False,state='verification_required')
|
|
414
|
+
return result
|
|
415
|
+
def _reservation(self,operation,operation_id,extra):
|
|
416
|
+
if not operation_id: raise ValueError('retain operation_id for retries')
|
|
417
|
+
with self.lock:
|
|
418
|
+
activation=self._check()['activation_id']
|
|
419
|
+
try:
|
|
420
|
+
result=self._request('consume/'+operation,activation,operation_id,extra)['reservation'];self._publish(True);return result
|
|
421
|
+
except Exception as e:
|
|
422
|
+
if getattr(e,'code','') in ['LICENSE_REVOKED','LICENSE_SUSPENDED','LICENSE_EXPIRED','DEVICE_RELEASED','DEVICE_CREDENTIAL_REVOKED']: self._invalidate()
|
|
423
|
+
self._publish(False,e);raise
|
|
424
|
+
def reserve(self,feature,quantity,operation_id): return self._reservation('reserve',operation_id,{'feature':feature,'quantity':quantity})
|
|
425
|
+
def commit(self,reservation_id,operation_id): return self._reservation('commit',operation_id,{'reservation_id':reservation_id})
|
|
426
|
+
def cancel(self,reservation_id,operation_id): return self._reservation('cancel',operation_id,{'reservation_id':reservation_id})
|
|
427
|
+
def stop_client(self):
|
|
428
|
+
self.close()
|
|
429
|
+
def __enter__(self): return self
|
|
430
|
+
def __exit__(self,*args): self.close()
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: licentivo-sdk
|
|
3
|
+
Version: 0.10.0
|
|
4
|
+
Summary: Licentivo software licensing client
|
|
5
|
+
License: MIT License
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2026 spf86 and Licentivo contributors
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
in the Software without restriction, including without limitation the rights
|
|
12
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
furnished to do so, subject to the following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be included in all
|
|
17
|
+
copies or substantial portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
23
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
SOFTWARE.
|
|
26
|
+
|
|
27
|
+
Project-URL: Homepage, https://github.com/spf86/licentivo-sdk
|
|
28
|
+
Project-URL: Documentation, https://www.licentivo.com/en/docs.html
|
|
29
|
+
Project-URL: Issues, https://github.com/spf86/licentivo-sdk/issues
|
|
30
|
+
Requires-Python: >=3.10
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
License-File: LICENSE
|
|
33
|
+
License-File: NOTICE
|
|
34
|
+
Requires-Dist: cryptography<49,>=44.0.1
|
|
35
|
+
Dynamic: license-file
|
|
36
|
+
|
|
37
|
+
# Licentivo Python SDK
|
|
38
|
+
|
|
39
|
+
Add software license activation and protected features to a Python application without implementing the licensing HTTP protocol yourself.
|
|
40
|
+
|
|
41
|
+
## Features
|
|
42
|
+
|
|
43
|
+
- Activates a customer license, derives device identity, stores a private device credential and restores it after restart.
|
|
44
|
+
- Verifies signed grants with pinned public keys and checks license/feature status before running your business callback.
|
|
45
|
+
- Schedules policy heartbeats, supports permitted offline grants and returns floating seats on close.
|
|
46
|
+
- Handles metered-feature reservation, commit/cancel and recovery of pending confirmations using a stable job ID.
|
|
47
|
+
- Provides explicit refresh/deactivation, offline activation file exchange and structured error details.
|
|
48
|
+
|
|
49
|
+
Your application supplies the activation interface, business function and durable business results. The SDK handles the authorization workflow; management API keys stay on your server.
|
|
50
|
+
|
|
51
|
+
## Installation
|
|
52
|
+
|
|
53
|
+
Use Python 3.10+ and a virtual environment. Install the pinned release from PyPI:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
python -m pip install "licentivo-sdk==0.10.0"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
For an offline release, install local sources with `python -m pip install ./sdk/python`,
|
|
60
|
+
or a downloaded wheel with `python -m pip install ./licentivo_sdk-0.10.0-py3-none-any.whl`.
|
|
61
|
+
The [website installation guide](https://www.licentivo.com/en/docs.html#sdk) shows current release availability and the common installer.
|
|
62
|
+
For the full starter, see QUICKSTART.md at the configured ZIP root and run `start.ps1` / `start.sh`.
|
|
63
|
+
Keep `product.json` in application resources. Read the customer's key from your
|
|
64
|
+
activation UI; after activation pass an empty string to restore the credential.
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from licentivo import Client
|
|
68
|
+
from pathlib import Path
|
|
69
|
+
|
|
70
|
+
with Client.open_with_license('product.json', customer_key, on_error=print) as client:
|
|
71
|
+
client.run_feature('export', lambda: Path('report.txt').write_text('Report\n'))
|
|
72
|
+
# In your app, keep this context open until the application exits.
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
For a configured metered feature, pass your business callback to the SDK:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
client.run_metered_feature('export', 1, persistent_job_id, export_report)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The SDK reserves, runs, confirms/cancels and stores a private journal. Retain your
|
|
82
|
+
job ID and durable business result. Repeating this call after a pending commit,
|
|
83
|
+
including after restart, retries only confirmation and never repeats business.
|
|
84
|
+
If a process crashes during the callback, inspect its result and call
|
|
85
|
+
`resolve_metered_feature(job_id, succeeded)`; the SDK does not guess or rerun it.
|
|
86
|
+
One workflow runs per client; different processes need different job IDs.
|
|
87
|
+
The lower-level reserve/commit/cancel methods remain available for custom flows.
|
|
88
|
+
The counter requires an online connection. `consume()` immediately charges
|
|
89
|
+
usage and is for operations that cannot subsequently fail.
|
|
90
|
+
|
|
91
|
+
`offline_request()` returns a dictionary to save as JSON;
|
|
92
|
+
`import_offline(request_dict, response_dict)` verifies an offline activation.
|
|
93
|
+
Closing a floating client releases its seat; device bindings are kept.
|
|
94
|
+
|
|
95
|
+
Accept an ordinary `lv_lic_...` key or a replacement computer's `lv_tmp_...`
|
|
96
|
+
code through your activation UI. After activation, the SDK saves a machine-scoped
|
|
97
|
+
credential beside `cache_path`. Later starts can use an empty `license_key`
|
|
98
|
+
with the same product, trusted keys and cache path. Keep the credential in the
|
|
99
|
+
user's application data directory; never ship it with the software.
|
|
100
|
+
|
|
101
|
+
With a valid device-bound offline cache, startup restores access immediately
|
|
102
|
+
and refreshes online in the background. Online-only and floating licenses wait
|
|
103
|
+
for the server. `feature_status('export')` explains a denial without a network
|
|
104
|
+
request. Call `close()` on exit. Python's synchronous HTTP transport may finish
|
|
105
|
+
an in-flight request after close; its timeout bounds that work.
|
|
106
|
+
|
|
107
|
+
Run `python desktop.py product.json` for an activation and report-export window.
|
|
108
|
+
It retains an export journal for commit recovery. An interrupted output still
|
|
109
|
+
requires your application to check whether the business operation completed.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
NOTICE
|
|
3
|
+
README.md
|
|
4
|
+
licentivo.py
|
|
5
|
+
licentivo_tasks.py
|
|
6
|
+
pyproject.toml
|
|
7
|
+
licentivo_sdk.egg-info/PKG-INFO
|
|
8
|
+
licentivo_sdk.egg-info/SOURCES.txt
|
|
9
|
+
licentivo_sdk.egg-info/dependency_links.txt
|
|
10
|
+
licentivo_sdk.egg-info/requires.txt
|
|
11
|
+
licentivo_sdk.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cryptography<49,>=44.0.1
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Private task journal. It contains IDs/outcome only, never customer business data."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _path(client, operation_id):
|
|
10
|
+
if not isinstance(operation_id, str) or not 1 <= len(operation_id) <= 80 or any(ord(char) < 33 or ord(char) > 126 for char in operation_id):
|
|
11
|
+
raise ValueError('a stable operation_id of 1-80 non-whitespace ASCII characters is required')
|
|
12
|
+
digest = hashlib.sha256((client._hash() + '\n' + operation_id).encode()).hexdigest()
|
|
13
|
+
return Path(client.config['cache_path'] + '.task-' + digest + '.json')
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _read(path, operation_id):
|
|
17
|
+
if not path.exists():
|
|
18
|
+
return dict(version=1, operation_id=operation_id, reservation_id='', state='')
|
|
19
|
+
raw = path.read_bytes()
|
|
20
|
+
task = json.loads(raw) if len(raw) <= 4096 else None
|
|
21
|
+
if not isinstance(task, dict) or task.get('version') != 1 or task.get('operation_id') != operation_id or not isinstance(task.get('feature'), str) or not task['feature'] or not isinstance(task.get('quantity'), int) or task['quantity'] <= 0 or not isinstance(task.get('state'), str) or not isinstance(task.get('reservation_id'), str) or not task['reservation_id']:
|
|
22
|
+
raise ValueError('invalid feature task journal; do not repeat business')
|
|
23
|
+
return task
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _save(path, task):
|
|
27
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
28
|
+
fd, temporary = tempfile.mkstemp(prefix='.feature-task-', dir=path.parent)
|
|
29
|
+
try:
|
|
30
|
+
with os.fdopen(fd, 'w', encoding='utf-8') as stream:
|
|
31
|
+
json.dump(task, stream)
|
|
32
|
+
stream.flush()
|
|
33
|
+
os.fsync(stream.fileno())
|
|
34
|
+
os.replace(temporary, path)
|
|
35
|
+
finally:
|
|
36
|
+
Path(temporary).unlink(missing_ok=True)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _finish(client, path, task, succeeded):
|
|
40
|
+
try:
|
|
41
|
+
result = (client.commit if succeeded else client.cancel)(task['reservation_id'], task['operation_id'])
|
|
42
|
+
except Exception as error:
|
|
43
|
+
raise RuntimeError(f"confirmation pending for operation {task['operation_id']}; do not repeat business") from error
|
|
44
|
+
expected = 'committed' if succeeded else 'canceled'
|
|
45
|
+
if result['status'] != expected:
|
|
46
|
+
raise RuntimeError('reservation closed without the requested outcome; do not repeat business')
|
|
47
|
+
task['state'] = expected
|
|
48
|
+
_save(path, task)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def run(client, feature, quantity, operation_id, action):
|
|
52
|
+
if not callable(action) or not isinstance(quantity, int) or isinstance(quantity, bool) or quantity <= 0:
|
|
53
|
+
raise ValueError('business action and positive integer quantity are required')
|
|
54
|
+
if not client.feature_task_lock.acquire(blocking=False):
|
|
55
|
+
raise RuntimeError('another feature task is running')
|
|
56
|
+
try:
|
|
57
|
+
path = _path(client, operation_id)
|
|
58
|
+
task = _read(path, operation_id)
|
|
59
|
+
state = task['state']
|
|
60
|
+
if state and (task['feature'] != feature or task['quantity'] != quantity):
|
|
61
|
+
raise ValueError('operation ID was already used for another feature or quantity')
|
|
62
|
+
task.update(feature=feature, quantity=quantity)
|
|
63
|
+
if state == 'committed': return
|
|
64
|
+
if state == 'commit_pending': return _finish(client, path, task, True)
|
|
65
|
+
if state == 'cancel_pending': _finish(client, path, task, False); state = 'canceled'
|
|
66
|
+
if state == 'canceled': raise RuntimeError('operation canceled; use a new job ID for a new action')
|
|
67
|
+
if state == 'business_running': raise RuntimeError('business outcome unknown; verify its result and call resolve_metered_feature')
|
|
68
|
+
if state: raise ValueError('invalid feature task state')
|
|
69
|
+
client.run_feature(feature, lambda: None)
|
|
70
|
+
hold = client.reserve(feature, quantity, operation_id)
|
|
71
|
+
if hold['status'] == 'committed':
|
|
72
|
+
task.update(reservation_id=hold['reservation_id'], state='committed')
|
|
73
|
+
_save(path, task)
|
|
74
|
+
return
|
|
75
|
+
if hold['status'] != 'pending': raise RuntimeError('reservation is closed; business was not run')
|
|
76
|
+
task.update(reservation_id=hold['reservation_id'], state='business_running')
|
|
77
|
+
try: _save(path, task)
|
|
78
|
+
except Exception:
|
|
79
|
+
try: client.cancel(hold['reservation_id'], operation_id)
|
|
80
|
+
except Exception: pass
|
|
81
|
+
raise
|
|
82
|
+
try: action()
|
|
83
|
+
except Exception:
|
|
84
|
+
task['state'] = 'cancel_pending'
|
|
85
|
+
try: _save(path, task); _finish(client, path, task, False)
|
|
86
|
+
except Exception: pass # The original business error remains authoritative.
|
|
87
|
+
raise
|
|
88
|
+
task['state'] = 'commit_pending'
|
|
89
|
+
try: _save(path, task)
|
|
90
|
+
except Exception as error:
|
|
91
|
+
raise RuntimeError(f'business completed; journal update failed; do not repeat operation {operation_id}') from error
|
|
92
|
+
_finish(client, path, task, True)
|
|
93
|
+
finally:
|
|
94
|
+
client.feature_task_lock.release()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def resolve(client, operation_id, succeeded):
|
|
98
|
+
if not client.feature_task_lock.acquire(blocking=False): raise RuntimeError('another feature task is running')
|
|
99
|
+
try:
|
|
100
|
+
path = _path(client, operation_id)
|
|
101
|
+
task = _read(path, operation_id)
|
|
102
|
+
state = task['state']
|
|
103
|
+
if state == ('committed' if succeeded else 'canceled'): return
|
|
104
|
+
if not isinstance(succeeded, bool) or not task['reservation_id'] or state not in ('business_running', 'commit_pending', 'cancel_pending') or state == 'commit_pending' and not succeeded or state == 'cancel_pending' and succeeded:
|
|
105
|
+
raise RuntimeError('task cannot be resolved with that outcome')
|
|
106
|
+
task['state'] = 'commit_pending' if succeeded else 'cancel_pending'
|
|
107
|
+
_save(path, task)
|
|
108
|
+
_finish(client, path, task, succeeded)
|
|
109
|
+
finally:
|
|
110
|
+
client.feature_task_lock.release()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "licentivo-sdk"
|
|
7
|
+
version = "0.10.0"
|
|
8
|
+
description = "Licentivo software licensing client"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = {file = "LICENSE"}
|
|
12
|
+
dependencies = ["cryptography>=44.0.1,<49"]
|
|
13
|
+
|
|
14
|
+
[project.urls]
|
|
15
|
+
Homepage = "https://github.com/spf86/licentivo-sdk"
|
|
16
|
+
Documentation = "https://www.licentivo.com/en/docs.html"
|
|
17
|
+
Issues = "https://github.com/spf86/licentivo-sdk/issues"
|
|
18
|
+
|
|
19
|
+
[tool.setuptools]
|
|
20
|
+
py-modules = ["licentivo", "licentivo_tasks"]
|
|
21
|
+
license-files = ["LICENSE", "NOTICE"]
|