pylicensify 1.0.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.
- pylicensify-1.0.0/PKG-INFO +129 -0
- pylicensify-1.0.0/README.md +113 -0
- pylicensify-1.0.0/pyproject.toml +28 -0
- pylicensify-1.0.0/setup.cfg +4 -0
- pylicensify-1.0.0/src/pylicensify/__init__.py +34 -0
- pylicensify-1.0.0/src/pylicensify/_hwid.py +8 -0
- pylicensify-1.0.0/src/pylicensify/client.py +222 -0
- pylicensify-1.0.0/src/pylicensify/errors.py +25 -0
- pylicensify-1.0.0/src/pylicensify/offline.py +62 -0
- pylicensify-1.0.0/src/pylicensify.egg-info/PKG-INFO +129 -0
- pylicensify-1.0.0/src/pylicensify.egg-info/SOURCES.txt +12 -0
- pylicensify-1.0.0/src/pylicensify.egg-info/dependency_links.txt +1 -0
- pylicensify-1.0.0/src/pylicensify.egg-info/requires.txt +2 -0
- pylicensify-1.0.0/src/pylicensify.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pylicensify
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Client SDK for PyLicense — verify software license keys with tamper-proof, signed responses.
|
|
5
|
+
Author: PyLicense
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://license.pyobfuscate.com
|
|
8
|
+
Keywords: license,licensing,activation,drm,software-licensing
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Requires-Python: >=3.8
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Requires-Dist: requests>=2.25
|
|
15
|
+
Requires-Dist: cryptography>=41.0
|
|
16
|
+
|
|
17
|
+
# pylicensify
|
|
18
|
+
|
|
19
|
+
Client SDK for [PyLicense](https://license.pyobfuscate.com) — verify software
|
|
20
|
+
license keys with **tamper-proof, signed responses**. A cracked "fake server"
|
|
21
|
+
can't grant access, because every response is verified against your public key.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install pylicensify
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
Get your **public key** from the dashboard → *Integration* page (safe to embed
|
|
30
|
+
in your distributed app; the private key never leaves the server).
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from pylicensify import LicenseClient
|
|
34
|
+
|
|
35
|
+
client = LicenseClient(public_key="YOUR_PUBLIC_KEY")
|
|
36
|
+
|
|
37
|
+
result = client.validate("PY-XXXXXXXXXXXXXXXX")
|
|
38
|
+
if result:
|
|
39
|
+
print("Licensed. Entitlements:", result.features)
|
|
40
|
+
if result.feature("pro"):
|
|
41
|
+
enable_pro_features()
|
|
42
|
+
else:
|
|
43
|
+
print("Not licensed:", result.error)
|
|
44
|
+
raise SystemExit(1)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`validate()` sends a nonce, verifies the Ed25519 signature, and rejects stale /
|
|
48
|
+
replayed responses automatically. It raises `SignatureError` on tampering and
|
|
49
|
+
`NetworkError` if the server is unreachable.
|
|
50
|
+
|
|
51
|
+
## Feature gating
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
result = client.validate(key)
|
|
55
|
+
seats = result.feature("seats", 1)
|
|
56
|
+
if result.feature("pro"):
|
|
57
|
+
...
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Offline license files (air-gapped)
|
|
61
|
+
|
|
62
|
+
Issue a signed `.lic` file from a license row in the dashboard, ship it with the
|
|
63
|
+
app, and verify it locally with **zero network calls**:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from pylicensify import verify_offline_license
|
|
67
|
+
|
|
68
|
+
data = verify_offline_license("YOUR_PUBLIC_KEY", "license.lic")
|
|
69
|
+
print("Valid for:", data.get("customer"), data.get("features"))
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Checks signature, expiry, device binding, and OS lock.
|
|
73
|
+
|
|
74
|
+
## Floating / concurrent licenses
|
|
75
|
+
|
|
76
|
+
For keys with a concurrent-session limit — the SDK checks out a seat, heartbeats
|
|
77
|
+
in the background, and releases on exit:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from pylicensify import LicenseClient, SeatUnavailable
|
|
81
|
+
|
|
82
|
+
client = LicenseClient(public_key="YOUR_PUBLIC_KEY")
|
|
83
|
+
try:
|
|
84
|
+
with client.session("PY-XXXX...") as sess:
|
|
85
|
+
print("Seat acquired:", sess.seats) # {'used': 1, 'max': 5}
|
|
86
|
+
run_app() # released automatically on exit
|
|
87
|
+
except SeatUnavailable:
|
|
88
|
+
print("All seats are in use — try again later.")
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Update checks
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
info = client.check_update("PY-XXXX...", current_version="1.0.0")
|
|
95
|
+
if info["update_available"]:
|
|
96
|
+
print("New version:", info["version"], info["notes"])
|
|
97
|
+
# info["download_url"] is a license-gated download link
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Self-hosted / custom domain
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
client = LicenseClient(public_key="...", api_url="https://license.yourdomain.com")
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Hardware ID
|
|
107
|
+
|
|
108
|
+
By default the SDK derives a machine id from `uuid.getnode()`. Pass your own for
|
|
109
|
+
a stronger fingerprint:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
client.validate(key, hwid=my_custom_fingerprint())
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Errors
|
|
116
|
+
|
|
117
|
+
| Exception | Meaning |
|
|
118
|
+
|---|---|
|
|
119
|
+
| `InvalidLicense` | Server rejected the key (invalid/expired/revoked/blocked) |
|
|
120
|
+
| `SignatureError` | Signature/nonce/timestamp failed — forged or replayed |
|
|
121
|
+
| `NetworkError` | Couldn't reach the license server |
|
|
122
|
+
| `SeatUnavailable` | Floating license has no free seats |
|
|
123
|
+
|
|
124
|
+
All inherit from `LicenseError`.
|
|
125
|
+
|
|
126
|
+
## Hardening
|
|
127
|
+
|
|
128
|
+
Obfuscate your client so the check itself can't be trivially patched out —
|
|
129
|
+
e.g. with [PyObfuscate](https://pyobfuscate.com).
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# pylicensify
|
|
2
|
+
|
|
3
|
+
Client SDK for [PyLicense](https://license.pyobfuscate.com) — verify software
|
|
4
|
+
license keys with **tamper-proof, signed responses**. A cracked "fake server"
|
|
5
|
+
can't grant access, because every response is verified against your public key.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install pylicensify
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
Get your **public key** from the dashboard → *Integration* page (safe to embed
|
|
14
|
+
in your distributed app; the private key never leaves the server).
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from pylicensify import LicenseClient
|
|
18
|
+
|
|
19
|
+
client = LicenseClient(public_key="YOUR_PUBLIC_KEY")
|
|
20
|
+
|
|
21
|
+
result = client.validate("PY-XXXXXXXXXXXXXXXX")
|
|
22
|
+
if result:
|
|
23
|
+
print("Licensed. Entitlements:", result.features)
|
|
24
|
+
if result.feature("pro"):
|
|
25
|
+
enable_pro_features()
|
|
26
|
+
else:
|
|
27
|
+
print("Not licensed:", result.error)
|
|
28
|
+
raise SystemExit(1)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`validate()` sends a nonce, verifies the Ed25519 signature, and rejects stale /
|
|
32
|
+
replayed responses automatically. It raises `SignatureError` on tampering and
|
|
33
|
+
`NetworkError` if the server is unreachable.
|
|
34
|
+
|
|
35
|
+
## Feature gating
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
result = client.validate(key)
|
|
39
|
+
seats = result.feature("seats", 1)
|
|
40
|
+
if result.feature("pro"):
|
|
41
|
+
...
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Offline license files (air-gapped)
|
|
45
|
+
|
|
46
|
+
Issue a signed `.lic` file from a license row in the dashboard, ship it with the
|
|
47
|
+
app, and verify it locally with **zero network calls**:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from pylicensify import verify_offline_license
|
|
51
|
+
|
|
52
|
+
data = verify_offline_license("YOUR_PUBLIC_KEY", "license.lic")
|
|
53
|
+
print("Valid for:", data.get("customer"), data.get("features"))
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Checks signature, expiry, device binding, and OS lock.
|
|
57
|
+
|
|
58
|
+
## Floating / concurrent licenses
|
|
59
|
+
|
|
60
|
+
For keys with a concurrent-session limit — the SDK checks out a seat, heartbeats
|
|
61
|
+
in the background, and releases on exit:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from pylicensify import LicenseClient, SeatUnavailable
|
|
65
|
+
|
|
66
|
+
client = LicenseClient(public_key="YOUR_PUBLIC_KEY")
|
|
67
|
+
try:
|
|
68
|
+
with client.session("PY-XXXX...") as sess:
|
|
69
|
+
print("Seat acquired:", sess.seats) # {'used': 1, 'max': 5}
|
|
70
|
+
run_app() # released automatically on exit
|
|
71
|
+
except SeatUnavailable:
|
|
72
|
+
print("All seats are in use — try again later.")
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Update checks
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
info = client.check_update("PY-XXXX...", current_version="1.0.0")
|
|
79
|
+
if info["update_available"]:
|
|
80
|
+
print("New version:", info["version"], info["notes"])
|
|
81
|
+
# info["download_url"] is a license-gated download link
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Self-hosted / custom domain
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
client = LicenseClient(public_key="...", api_url="https://license.yourdomain.com")
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Hardware ID
|
|
91
|
+
|
|
92
|
+
By default the SDK derives a machine id from `uuid.getnode()`. Pass your own for
|
|
93
|
+
a stronger fingerprint:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
client.validate(key, hwid=my_custom_fingerprint())
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Errors
|
|
100
|
+
|
|
101
|
+
| Exception | Meaning |
|
|
102
|
+
|---|---|
|
|
103
|
+
| `InvalidLicense` | Server rejected the key (invalid/expired/revoked/blocked) |
|
|
104
|
+
| `SignatureError` | Signature/nonce/timestamp failed — forged or replayed |
|
|
105
|
+
| `NetworkError` | Couldn't reach the license server |
|
|
106
|
+
| `SeatUnavailable` | Floating license has no free seats |
|
|
107
|
+
|
|
108
|
+
All inherit from `LicenseError`.
|
|
109
|
+
|
|
110
|
+
## Hardening
|
|
111
|
+
|
|
112
|
+
Obfuscate your client so the check itself can't be trivially patched out —
|
|
113
|
+
e.g. with [PyObfuscate](https://pyobfuscate.com).
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pylicensify"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Client SDK for PyLicense — verify software license keys with tamper-proof, signed responses."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "PyLicense" }]
|
|
13
|
+
keywords = ["license", "licensing", "activation", "drm", "software-licensing"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
]
|
|
19
|
+
dependencies = [
|
|
20
|
+
"requests>=2.25",
|
|
21
|
+
"cryptography>=41.0",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://license.pyobfuscate.com"
|
|
26
|
+
|
|
27
|
+
[tool.setuptools.packages.find]
|
|
28
|
+
where = ["src"]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""PyLicense client SDK — verify software license keys with tamper-proof,
|
|
2
|
+
signed responses.
|
|
3
|
+
|
|
4
|
+
from pylicensify import LicenseClient
|
|
5
|
+
|
|
6
|
+
client = LicenseClient(public_key="YOUR_PUBLIC_KEY")
|
|
7
|
+
result = client.validate("PY-XXXX...")
|
|
8
|
+
if result:
|
|
9
|
+
print("Licensed!", result.features)
|
|
10
|
+
"""
|
|
11
|
+
from .client import LicenseClient, ValidationResult, FloatingSession
|
|
12
|
+
from .offline import verify_offline_license
|
|
13
|
+
from ._hwid import get_hwid
|
|
14
|
+
from .errors import (
|
|
15
|
+
LicenseError,
|
|
16
|
+
NetworkError,
|
|
17
|
+
InvalidLicense,
|
|
18
|
+
SignatureError,
|
|
19
|
+
SeatUnavailable,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__version__ = "1.0.0"
|
|
23
|
+
__all__ = [
|
|
24
|
+
"LicenseClient",
|
|
25
|
+
"ValidationResult",
|
|
26
|
+
"FloatingSession",
|
|
27
|
+
"verify_offline_license",
|
|
28
|
+
"get_hwid",
|
|
29
|
+
"LicenseError",
|
|
30
|
+
"NetworkError",
|
|
31
|
+
"InvalidLicense",
|
|
32
|
+
"SignatureError",
|
|
33
|
+
"SeatUnavailable",
|
|
34
|
+
]
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Online license validation, floating sessions, and update checks."""
|
|
2
|
+
import base64
|
|
3
|
+
import platform as _plat
|
|
4
|
+
import secrets
|
|
5
|
+
import socket
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
11
|
+
from cryptography.exceptions import InvalidSignature
|
|
12
|
+
|
|
13
|
+
from ._hwid import get_hwid
|
|
14
|
+
from .errors import NetworkError, InvalidLicense, SignatureError, SeatUnavailable
|
|
15
|
+
|
|
16
|
+
DEFAULT_API = "https://license.pyobfuscate.com"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ValidationResult:
|
|
20
|
+
"""Outcome of :meth:`LicenseClient.validate`."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, valid, product_id=None, expires_at=None, features=None, raw=None, error=None):
|
|
23
|
+
self.valid = valid
|
|
24
|
+
self.product_id = product_id
|
|
25
|
+
self.expires_at = expires_at # ISO string or None (lifetime)
|
|
26
|
+
self.features = features or {} # signed entitlements, e.g. {"pro": True}
|
|
27
|
+
self.error = error # server message when not valid
|
|
28
|
+
self.raw = raw or {}
|
|
29
|
+
|
|
30
|
+
def feature(self, name, default=None):
|
|
31
|
+
return self.features.get(name, default)
|
|
32
|
+
|
|
33
|
+
def __bool__(self):
|
|
34
|
+
return self.valid
|
|
35
|
+
|
|
36
|
+
def __repr__(self):
|
|
37
|
+
return f"<ValidationResult valid={self.valid} product={self.product_id} features={self.features}>"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class LicenseClient:
|
|
41
|
+
"""Verifies license keys against a PyLicense server.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
public_key: your account's Ed25519 public key (from the dashboard's
|
|
45
|
+
Integration page). Safe to embed in your distributed app.
|
|
46
|
+
api_url: license server base URL (defaults to the hosted platform).
|
|
47
|
+
timeout: per-request timeout in seconds.
|
|
48
|
+
max_skew: reject signed responses older than this many seconds (replay guard).
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, public_key, api_url=DEFAULT_API, timeout=5, max_skew=300):
|
|
52
|
+
if not public_key:
|
|
53
|
+
raise ValueError("public_key is required (get it from the dashboard)")
|
|
54
|
+
self._pub = Ed25519PublicKey.from_public_bytes(base64.b64decode(public_key))
|
|
55
|
+
self.api_url = api_url.rstrip("/")
|
|
56
|
+
self.timeout = timeout
|
|
57
|
+
self.max_skew = max_skew
|
|
58
|
+
|
|
59
|
+
# ---- Online validation -------------------------------------------------
|
|
60
|
+
def validate(self, key, hwid=None, device_name=None, raise_on_invalid=False):
|
|
61
|
+
"""Validate a key online and verify the signed response.
|
|
62
|
+
|
|
63
|
+
Returns a :class:`ValidationResult`. Set ``raise_on_invalid=True`` to
|
|
64
|
+
raise :class:`InvalidLicense` instead of returning ``valid=False``.
|
|
65
|
+
Always raises :class:`SignatureError` on tamper/replay and
|
|
66
|
+
:class:`NetworkError` if the server is unreachable.
|
|
67
|
+
"""
|
|
68
|
+
hwid = hwid or get_hwid()
|
|
69
|
+
nonce = secrets.token_hex(8)
|
|
70
|
+
try:
|
|
71
|
+
r = requests.post(
|
|
72
|
+
f"{self.api_url}/api/v1/validate",
|
|
73
|
+
json={
|
|
74
|
+
"key": key,
|
|
75
|
+
"hwid": hwid,
|
|
76
|
+
"nonce": nonce,
|
|
77
|
+
"device_name": device_name or socket.gethostname(),
|
|
78
|
+
"platform": f"{_plat.system()} {_plat.release()}",
|
|
79
|
+
},
|
|
80
|
+
timeout=self.timeout,
|
|
81
|
+
)
|
|
82
|
+
except requests.RequestException as e:
|
|
83
|
+
raise NetworkError(str(e))
|
|
84
|
+
|
|
85
|
+
data = r.json() if r.content else {}
|
|
86
|
+
if r.status_code != 200 or not data.get("valid"):
|
|
87
|
+
msg = data.get("error", "License is not valid")
|
|
88
|
+
if raise_on_invalid:
|
|
89
|
+
raise InvalidLicense(msg, status=r.status_code)
|
|
90
|
+
return ValidationResult(False, raw=data, error=msg)
|
|
91
|
+
|
|
92
|
+
self._verify_signature(data, hwid, nonce)
|
|
93
|
+
return ValidationResult(
|
|
94
|
+
True,
|
|
95
|
+
product_id=data.get("product_id"),
|
|
96
|
+
expires_at=data.get("expires_at"),
|
|
97
|
+
features=data.get("features"),
|
|
98
|
+
raw=data,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def _verify_signature(self, data, hwid, nonce):
|
|
102
|
+
sig = data.get("signature")
|
|
103
|
+
if not sig:
|
|
104
|
+
raise SignatureError("Response was not signed — refusing to trust it")
|
|
105
|
+
message = (
|
|
106
|
+
f"pylicense.v2|{data['product_id']}|{hwid}|"
|
|
107
|
+
f"{data.get('expires_at') or ''}|{nonce}|{data['issued_at']}|"
|
|
108
|
+
f"{data.get('features_b64', '')}"
|
|
109
|
+
)
|
|
110
|
+
try:
|
|
111
|
+
self._pub.verify(base64.b64decode(sig), message.encode())
|
|
112
|
+
except (InvalidSignature, Exception):
|
|
113
|
+
raise SignatureError("Signature verification failed — response may be forged")
|
|
114
|
+
if data.get("nonce") != nonce:
|
|
115
|
+
raise SignatureError("Nonce mismatch — possible replay")
|
|
116
|
+
if abs(time.time() - data["issued_at"]) > self.max_skew:
|
|
117
|
+
raise SignatureError("Stale response — possible replay")
|
|
118
|
+
|
|
119
|
+
# ---- Floating / concurrent sessions ------------------------------------
|
|
120
|
+
def lease(self, key, hwid=None):
|
|
121
|
+
"""Reserve a concurrent seat. Returns the lease dict (lease_id, interval, seats)."""
|
|
122
|
+
hwid = hwid or get_hwid()
|
|
123
|
+
try:
|
|
124
|
+
r = requests.post(f"{self.api_url}/api/v1/lease", json={"key": key, "hwid": hwid}, timeout=self.timeout)
|
|
125
|
+
except requests.RequestException as e:
|
|
126
|
+
raise NetworkError(str(e))
|
|
127
|
+
data = r.json() if r.content else {}
|
|
128
|
+
if r.status_code == 409:
|
|
129
|
+
raise SeatUnavailable(data.get("error", "All concurrent sessions are in use"))
|
|
130
|
+
if r.status_code != 200:
|
|
131
|
+
raise InvalidLicense(data.get("error", "Could not acquire a seat"), status=r.status_code)
|
|
132
|
+
return data
|
|
133
|
+
|
|
134
|
+
def heartbeat(self, lease_id):
|
|
135
|
+
"""Keep a seat alive. Returns True if still held, False if it was released/expired."""
|
|
136
|
+
try:
|
|
137
|
+
r = requests.post(f"{self.api_url}/api/v1/heartbeat", json={"lease_id": lease_id}, timeout=self.timeout)
|
|
138
|
+
except requests.RequestException:
|
|
139
|
+
return True # transient network blip — assume still held, retry next tick
|
|
140
|
+
return r.status_code == 200
|
|
141
|
+
|
|
142
|
+
def release(self, lease_id):
|
|
143
|
+
"""Free a seat (idempotent)."""
|
|
144
|
+
try:
|
|
145
|
+
requests.post(f"{self.api_url}/api/v1/release", json={"lease_id": lease_id}, timeout=self.timeout)
|
|
146
|
+
except requests.RequestException:
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
def session(self, key, hwid=None):
|
|
150
|
+
"""Context manager that checks out a seat, heartbeats in the background,
|
|
151
|
+
and releases on exit::
|
|
152
|
+
|
|
153
|
+
with client.session(key):
|
|
154
|
+
run_app()
|
|
155
|
+
"""
|
|
156
|
+
return FloatingSession(self, key, hwid)
|
|
157
|
+
|
|
158
|
+
# ---- Updates -----------------------------------------------------------
|
|
159
|
+
def check_update(self, key, current_version, channel="stable"):
|
|
160
|
+
"""Ask whether a newer build exists. Returns the update dict
|
|
161
|
+
(update_available, version, notes, download_url, size)."""
|
|
162
|
+
try:
|
|
163
|
+
r = requests.post(
|
|
164
|
+
f"{self.api_url}/api/v1/update",
|
|
165
|
+
json={"key": key, "current_version": current_version, "channel": channel},
|
|
166
|
+
timeout=self.timeout,
|
|
167
|
+
)
|
|
168
|
+
except requests.RequestException as e:
|
|
169
|
+
raise NetworkError(str(e))
|
|
170
|
+
data = r.json() if r.content else {}
|
|
171
|
+
if r.status_code != 200:
|
|
172
|
+
raise InvalidLicense(data.get("error", "Update check failed"), status=r.status_code)
|
|
173
|
+
return data
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class FloatingSession:
|
|
177
|
+
"""Holds a concurrent seat and heartbeats until released. Use via
|
|
178
|
+
:meth:`LicenseClient.session`."""
|
|
179
|
+
|
|
180
|
+
def __init__(self, client, key, hwid=None):
|
|
181
|
+
self._client = client
|
|
182
|
+
self._key = key
|
|
183
|
+
self._hwid = hwid
|
|
184
|
+
self.lease_id = None
|
|
185
|
+
self.seats = None
|
|
186
|
+
self._interval = 60
|
|
187
|
+
self._stop = threading.Event()
|
|
188
|
+
self._thread = None
|
|
189
|
+
|
|
190
|
+
def start(self):
|
|
191
|
+
data = self._client.lease(self._key, self._hwid) # raises SeatUnavailable if full
|
|
192
|
+
self.lease_id = data["lease_id"]
|
|
193
|
+
self.seats = data.get("seats")
|
|
194
|
+
self._interval = max(15, int(data.get("interval", 60)))
|
|
195
|
+
self._thread = threading.Thread(target=self._loop, daemon=True)
|
|
196
|
+
self._thread.start()
|
|
197
|
+
return self
|
|
198
|
+
|
|
199
|
+
def _loop(self):
|
|
200
|
+
while not self._stop.wait(self._interval):
|
|
201
|
+
if not self.lease_id:
|
|
202
|
+
return
|
|
203
|
+
if not self._client.heartbeat(self.lease_id):
|
|
204
|
+
self.lease_id = None # server released us; app can react via .active
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
@property
|
|
208
|
+
def active(self):
|
|
209
|
+
return self.lease_id is not None
|
|
210
|
+
|
|
211
|
+
def stop(self):
|
|
212
|
+
self._stop.set()
|
|
213
|
+
if self.lease_id:
|
|
214
|
+
self._client.release(self.lease_id)
|
|
215
|
+
self.lease_id = None
|
|
216
|
+
|
|
217
|
+
def __enter__(self):
|
|
218
|
+
return self.start()
|
|
219
|
+
|
|
220
|
+
def __exit__(self, *exc):
|
|
221
|
+
self.stop()
|
|
222
|
+
return False
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Exceptions raised by the PyLicense client."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class LicenseError(Exception):
|
|
5
|
+
"""Base class for all PyLicense errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class NetworkError(LicenseError):
|
|
9
|
+
"""The license server could not be reached."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class InvalidLicense(LicenseError):
|
|
13
|
+
"""The server rejected the license (invalid, expired, revoked, blocked...)."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, message, status=None):
|
|
16
|
+
super().__init__(message)
|
|
17
|
+
self.status = status
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SignatureError(LicenseError):
|
|
21
|
+
"""Response signature/nonce/timestamp check failed — possibly forged or replayed."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SeatUnavailable(LicenseError):
|
|
25
|
+
"""A floating license has no free concurrent seats right now."""
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Verify a signed offline ``.lic`` file with zero network calls."""
|
|
2
|
+
import base64
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
7
|
+
from cryptography.exceptions import InvalidSignature
|
|
8
|
+
|
|
9
|
+
from ._hwid import get_hwid
|
|
10
|
+
from .errors import SignatureError, InvalidLicense
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _detect_os() -> str:
|
|
14
|
+
import platform as _plat
|
|
15
|
+
s = _plat.system().lower()
|
|
16
|
+
if "win" in s:
|
|
17
|
+
return "windows"
|
|
18
|
+
if "darwin" in s or "mac" in s:
|
|
19
|
+
return "macos"
|
|
20
|
+
if "linux" in s:
|
|
21
|
+
return "linux"
|
|
22
|
+
return ""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def verify_offline_license(public_key: str, token_or_path: str, hwid: str = None) -> dict:
|
|
26
|
+
"""Verify an offline license file (or its raw token string).
|
|
27
|
+
|
|
28
|
+
Checks the Ed25519 signature, expiry, device binding, and OS lock — all
|
|
29
|
+
locally. Returns the license payload dict on success.
|
|
30
|
+
|
|
31
|
+
Raises :class:`SignatureError` if tampered, :class:`InvalidLicense` if
|
|
32
|
+
expired / wrong device / wrong OS.
|
|
33
|
+
"""
|
|
34
|
+
token = token_or_path
|
|
35
|
+
# If it looks like a path, read the file; otherwise treat it as the token.
|
|
36
|
+
if "." in token_or_path and "\n" not in token_or_path and len(token_or_path) < 4096:
|
|
37
|
+
try:
|
|
38
|
+
with open(token_or_path, "r", encoding="utf-8") as f:
|
|
39
|
+
token = f.read().strip()
|
|
40
|
+
except (OSError, ValueError):
|
|
41
|
+
token = token_or_path
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
payload_b64, sig_b64 = token.strip().split(".")
|
|
45
|
+
pub = Ed25519PublicKey.from_public_bytes(base64.b64decode(public_key))
|
|
46
|
+
pub.verify(base64.b64decode(sig_b64), payload_b64.encode())
|
|
47
|
+
except (InvalidSignature, ValueError, Exception):
|
|
48
|
+
raise SignatureError("Invalid or tampered license file")
|
|
49
|
+
|
|
50
|
+
data = json.loads(base64.b64decode(payload_b64))
|
|
51
|
+
|
|
52
|
+
if data.get("expires_at") and time.time() > data["expires_at"]:
|
|
53
|
+
raise InvalidLicense("License file has expired")
|
|
54
|
+
|
|
55
|
+
hwid = hwid or get_hwid()
|
|
56
|
+
if data.get("hwid") and data["hwid"] != hwid:
|
|
57
|
+
raise InvalidLicense("License file is not valid for this machine")
|
|
58
|
+
|
|
59
|
+
if data.get("os_lock") and data["os_lock"] != _detect_os():
|
|
60
|
+
raise InvalidLicense("License file is not valid on this operating system")
|
|
61
|
+
|
|
62
|
+
return data
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pylicensify
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Client SDK for PyLicense — verify software license keys with tamper-proof, signed responses.
|
|
5
|
+
Author: PyLicense
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://license.pyobfuscate.com
|
|
8
|
+
Keywords: license,licensing,activation,drm,software-licensing
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Requires-Python: >=3.8
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Requires-Dist: requests>=2.25
|
|
15
|
+
Requires-Dist: cryptography>=41.0
|
|
16
|
+
|
|
17
|
+
# pylicensify
|
|
18
|
+
|
|
19
|
+
Client SDK for [PyLicense](https://license.pyobfuscate.com) — verify software
|
|
20
|
+
license keys with **tamper-proof, signed responses**. A cracked "fake server"
|
|
21
|
+
can't grant access, because every response is verified against your public key.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install pylicensify
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
Get your **public key** from the dashboard → *Integration* page (safe to embed
|
|
30
|
+
in your distributed app; the private key never leaves the server).
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from pylicensify import LicenseClient
|
|
34
|
+
|
|
35
|
+
client = LicenseClient(public_key="YOUR_PUBLIC_KEY")
|
|
36
|
+
|
|
37
|
+
result = client.validate("PY-XXXXXXXXXXXXXXXX")
|
|
38
|
+
if result:
|
|
39
|
+
print("Licensed. Entitlements:", result.features)
|
|
40
|
+
if result.feature("pro"):
|
|
41
|
+
enable_pro_features()
|
|
42
|
+
else:
|
|
43
|
+
print("Not licensed:", result.error)
|
|
44
|
+
raise SystemExit(1)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`validate()` sends a nonce, verifies the Ed25519 signature, and rejects stale /
|
|
48
|
+
replayed responses automatically. It raises `SignatureError` on tampering and
|
|
49
|
+
`NetworkError` if the server is unreachable.
|
|
50
|
+
|
|
51
|
+
## Feature gating
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
result = client.validate(key)
|
|
55
|
+
seats = result.feature("seats", 1)
|
|
56
|
+
if result.feature("pro"):
|
|
57
|
+
...
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Offline license files (air-gapped)
|
|
61
|
+
|
|
62
|
+
Issue a signed `.lic` file from a license row in the dashboard, ship it with the
|
|
63
|
+
app, and verify it locally with **zero network calls**:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from pylicensify import verify_offline_license
|
|
67
|
+
|
|
68
|
+
data = verify_offline_license("YOUR_PUBLIC_KEY", "license.lic")
|
|
69
|
+
print("Valid for:", data.get("customer"), data.get("features"))
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Checks signature, expiry, device binding, and OS lock.
|
|
73
|
+
|
|
74
|
+
## Floating / concurrent licenses
|
|
75
|
+
|
|
76
|
+
For keys with a concurrent-session limit — the SDK checks out a seat, heartbeats
|
|
77
|
+
in the background, and releases on exit:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from pylicensify import LicenseClient, SeatUnavailable
|
|
81
|
+
|
|
82
|
+
client = LicenseClient(public_key="YOUR_PUBLIC_KEY")
|
|
83
|
+
try:
|
|
84
|
+
with client.session("PY-XXXX...") as sess:
|
|
85
|
+
print("Seat acquired:", sess.seats) # {'used': 1, 'max': 5}
|
|
86
|
+
run_app() # released automatically on exit
|
|
87
|
+
except SeatUnavailable:
|
|
88
|
+
print("All seats are in use — try again later.")
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Update checks
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
info = client.check_update("PY-XXXX...", current_version="1.0.0")
|
|
95
|
+
if info["update_available"]:
|
|
96
|
+
print("New version:", info["version"], info["notes"])
|
|
97
|
+
# info["download_url"] is a license-gated download link
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Self-hosted / custom domain
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
client = LicenseClient(public_key="...", api_url="https://license.yourdomain.com")
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Hardware ID
|
|
107
|
+
|
|
108
|
+
By default the SDK derives a machine id from `uuid.getnode()`. Pass your own for
|
|
109
|
+
a stronger fingerprint:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
client.validate(key, hwid=my_custom_fingerprint())
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Errors
|
|
116
|
+
|
|
117
|
+
| Exception | Meaning |
|
|
118
|
+
|---|---|
|
|
119
|
+
| `InvalidLicense` | Server rejected the key (invalid/expired/revoked/blocked) |
|
|
120
|
+
| `SignatureError` | Signature/nonce/timestamp failed — forged or replayed |
|
|
121
|
+
| `NetworkError` | Couldn't reach the license server |
|
|
122
|
+
| `SeatUnavailable` | Floating license has no free seats |
|
|
123
|
+
|
|
124
|
+
All inherit from `LicenseError`.
|
|
125
|
+
|
|
126
|
+
## Hardening
|
|
127
|
+
|
|
128
|
+
Obfuscate your client so the check itself can't be trivially patched out —
|
|
129
|
+
e.g. with [PyObfuscate](https://pyobfuscate.com).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/pylicensify/__init__.py
|
|
4
|
+
src/pylicensify/_hwid.py
|
|
5
|
+
src/pylicensify/client.py
|
|
6
|
+
src/pylicensify/errors.py
|
|
7
|
+
src/pylicensify/offline.py
|
|
8
|
+
src/pylicensify.egg-info/PKG-INFO
|
|
9
|
+
src/pylicensify.egg-info/SOURCES.txt
|
|
10
|
+
src/pylicensify.egg-info/dependency_links.txt
|
|
11
|
+
src/pylicensify.egg-info/requires.txt
|
|
12
|
+
src/pylicensify.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pylicensify
|