icare-licensing 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- icare_licensing-1.0.0.dist-info/METADATA +154 -0
- icare_licensing-1.0.0.dist-info/RECORD +7 -0
- icare_licensing-1.0.0.dist-info/WHEEL +5 -0
- icare_licensing-1.0.0.dist-info/licenses/license.lic +1 -0
- icare_licensing-1.0.0.dist-info/licenses/license_metadata.json +4 -0
- icare_licensing-1.0.0.dist-info/top_level.txt +1 -0
- licensing_client.py +403 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: icare-licensing
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Secure cryptographic offline-first licensing client library for Icare licensing system
|
|
5
|
+
Author: Icare Infosystems
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Requires-Python: >=3.7
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: license.lic
|
|
12
|
+
License-File: license_metadata.json
|
|
13
|
+
Requires-Dist: requests>=2.25.0
|
|
14
|
+
Requires-Dist: cryptography>=3.0.0
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# Icare Licensing Client
|
|
18
|
+
|
|
19
|
+
`icare-liecensing-client` is an offline-first, cryptographically secure licensing client library for Python applications. It integrates seamlessly with the Icare licensing server using RSA-SHA256 signatures, device hardware bindings, and automated offline-resilient sync policies.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Features
|
|
24
|
+
- **Stable Hardware Binding (HWID)**: Generates a stable machine signature.
|
|
25
|
+
- **Offline Cryptographic Validation**: Validates license state offline using RSA-SHA256.
|
|
26
|
+
- **Automated Sync Daemon**: Runs in the background and pings the verification endpoint every 24 hours.
|
|
27
|
+
- **3-Day Offline Grace Period**: Remains fully functional offline for up to 3 days before requiring internet.
|
|
28
|
+
- **Instant Revocation Purge**: Deletes local signatures and metadata immediately upon server-side license revocation or deletion to prevent offline bypass.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## 1. Installation
|
|
33
|
+
|
|
34
|
+
Install the package from your PyPI registry:
|
|
35
|
+
```bash
|
|
36
|
+
pip install icare-licensing
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## 2. License Storage & File Location Policy
|
|
42
|
+
|
|
43
|
+
By default, the client library writes `license.lic` and `license_metadata.json` relative to the **Current Working Directory (CWD)** of the executing process.
|
|
44
|
+
|
|
45
|
+
### Critical Production Guidelines
|
|
46
|
+
For desktop deployments or system-wide CLI tools, running relative to the CWD can lead to:
|
|
47
|
+
1. **Permission Denials (`PermissionError`)**: If the app is run from a read-only directory (e.g. `Program Files` or `/usr/local/bin`).
|
|
48
|
+
2. **Missing Licenses**: If the user starts the application from a different terminal directory, the client will look in the wrong path and report the license is missing.
|
|
49
|
+
|
|
50
|
+
### Recommendation: Use Absolute Paths
|
|
51
|
+
Always initialize the client with an **absolute path** pointing to a user-writable application directory (such as the user's home folder or standard application data directory).
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
import os
|
|
55
|
+
from licensing_client import LicensingClient
|
|
56
|
+
|
|
57
|
+
# Determine a safe system path (e.g., ~/.config/my_app/ or %APPDATA%/my_app/)
|
|
58
|
+
app_folder = os.path.join(os.path.expanduser("~"), ".my_app")
|
|
59
|
+
os.makedirs(app_folder, exist_ok=True)
|
|
60
|
+
|
|
61
|
+
license_path = os.path.join(app_folder, "license.lic")
|
|
62
|
+
|
|
63
|
+
# Initialize client
|
|
64
|
+
client = LicensingClient(
|
|
65
|
+
server_url="https://licensing.mycompany.com",
|
|
66
|
+
license_file_path=license_path
|
|
67
|
+
)
|
|
68
|
+
```
|
|
69
|
+
*Note: The metadata file (`license_metadata.json`) will automatically be generated in the same target folder.*
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## 3. Core Developer API
|
|
74
|
+
|
|
75
|
+
### Licensing States
|
|
76
|
+
The library exposes six core status constants:
|
|
77
|
+
* `STATUS_VALID`: License signature is cryptographically valid, matches machine HWID, is not expired, and is synced.
|
|
78
|
+
* `STATUS_MISSING`: No `license.lic` file is present (Device needs activation).
|
|
79
|
+
* `STATUS_EXPIRED`: The local or server expiration date has been exceeded.
|
|
80
|
+
* `STATUS_INVALID`: The license cryptographic signature is corrupt, has been modified, or has been actively revoked/deleted on the server.
|
|
81
|
+
* `STATUS_REQUIRES_SYNC`: The application has been running offline for more than 3 days and requires internet to refresh.
|
|
82
|
+
* `STATUS_UNKNOWN`: State is not yet resolved.
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## 4. Integration Template
|
|
87
|
+
|
|
88
|
+
Copy this robust integration template into your project's main entry point:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
import os
|
|
92
|
+
import sys
|
|
93
|
+
from licensing_client import (
|
|
94
|
+
LicensingClient,
|
|
95
|
+
STATUS_VALID,
|
|
96
|
+
STATUS_REQUIRES_SYNC,
|
|
97
|
+
STATUS_MISSING,
|
|
98
|
+
STATUS_INVALID,
|
|
99
|
+
STATUS_EXPIRED
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def on_license_status_change(new_status):
|
|
103
|
+
"""
|
|
104
|
+
Callback triggered when the license status transitions.
|
|
105
|
+
NOTE: If running a GUI application (e.g., Tkinter/PyQt), make sure
|
|
106
|
+
to route UI-altering updates safely to the main thread.
|
|
107
|
+
"""
|
|
108
|
+
print(f"[Licensing] Status changed to: {new_status}")
|
|
109
|
+
|
|
110
|
+
if new_status == STATUS_VALID:
|
|
111
|
+
print("Premium features unlocked!")
|
|
112
|
+
# Proceed to launch normal application logic
|
|
113
|
+
|
|
114
|
+
elif new_status == STATUS_REQUIRES_SYNC:
|
|
115
|
+
print("Warning: internet connection required to verify license.")
|
|
116
|
+
# Optional: Show a warning banner, but keep standard features unlocked
|
|
117
|
+
|
|
118
|
+
elif new_status in (STATUS_MISSING, STATUS_INVALID, STATUS_EXPIRED):
|
|
119
|
+
print("Security Lock: No valid license active.")
|
|
120
|
+
# Action: Redirect user to your license activation overlay/screen
|
|
121
|
+
# Lock down core software capabilities
|
|
122
|
+
|
|
123
|
+
def main():
|
|
124
|
+
# 1. Setup secure storage paths
|
|
125
|
+
app_folder = os.path.join(os.path.expanduser("~"), ".my_app")
|
|
126
|
+
os.makedirs(app_folder, exist_ok=True)
|
|
127
|
+
license_path = os.path.join(app_folder, "license.lic")
|
|
128
|
+
|
|
129
|
+
# 2. Instantiate Client
|
|
130
|
+
# max_offline_days determines how long the app can run offline (default: 3 days)
|
|
131
|
+
client = LicensingClient(
|
|
132
|
+
server_url="http://localhost:8081",
|
|
133
|
+
license_file_path=license_path,
|
|
134
|
+
status_callback=on_license_status_change,
|
|
135
|
+
max_offline_days=3
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# 3. Handle Initial State
|
|
139
|
+
# Force a check immediately on startup
|
|
140
|
+
client._run_check()
|
|
141
|
+
|
|
142
|
+
if client.current_status == STATUS_MISSING:
|
|
143
|
+
print("Application is unactivated.")
|
|
144
|
+
# Example: prompt user for activation key in CLI
|
|
145
|
+
# key = input("Enter Activation Key: ")
|
|
146
|
+
# success, msg = client.activate_device(key)
|
|
147
|
+
|
|
148
|
+
# 4. Start Background Protection Thread
|
|
149
|
+
# Validates locally and checks online every 5 minutes (300 seconds)
|
|
150
|
+
client.start_monitoring(interval_seconds=300)
|
|
151
|
+
|
|
152
|
+
if __name__ == "__main__":
|
|
153
|
+
main()
|
|
154
|
+
```
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
licensing_client.py,sha256=-oWG34VUx5TGQDafCMCljH9KTXscECQBbu2G0jkoA1k,17247
|
|
2
|
+
icare_licensing-1.0.0.dist-info/licenses/license.lic,sha256=WG0_fKN8wUai1WkF_Ab5FDOt6j1q8gsnMTC3gTQjIEg,205
|
|
3
|
+
icare_licensing-1.0.0.dist-info/licenses/license_metadata.json,sha256=Lkb9xaIF4vBfNhGykUYIajMekO5Dk0cN7lh6Fr8WjRo,199
|
|
4
|
+
icare_licensing-1.0.0.dist-info/METADATA,sha256=HGbtjKF6kZAwufAHyzCUb5aLEONx_iUqyOyjGbP1ueE,6063
|
|
5
|
+
icare_licensing-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
6
|
+
icare_licensing-1.0.0.dist-info/top_level.txt,sha256=i-_Wemc8QhosRnFSyK0gVfMETpdwvklOtIZC2jjSM7U,17
|
|
7
|
+
icare_licensing-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bGljZW5zZUlkPTEwO3N0YXJ0RGF0ZT0yMDI2LTA1LTIyIDE4OjIzOjA5O3ZhbGlkRGF5cz0zNjU7aGFyZHdhcmVJZD1IVy1CNjVBNTBCOEVCMjJGMzk2.W5O/Ffr9TQkLbu0S8von0xktvU8UOkUTcQSveas7Pj1cOScUE3y1X/HsMHUQ3ZlKZUa6iXFU3oWo789tPbKeHw==
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
licensing_client
|
licensing_client.py
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import time
|
|
4
|
+
import uuid
|
|
5
|
+
import platform
|
|
6
|
+
import hashlib
|
|
7
|
+
import base64
|
|
8
|
+
import threading
|
|
9
|
+
from datetime import datetime, timedelta
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
# Cryptography dependencies for RSA signature verification
|
|
13
|
+
try:
|
|
14
|
+
from cryptography.hazmat.primitives.asymmetric import padding
|
|
15
|
+
from cryptography.hazmat.primitives import hashes
|
|
16
|
+
from cryptography.hazmat.primitives.serialization import load_der_public_key
|
|
17
|
+
CRYPTOGRAPHY_AVAILABLE = True
|
|
18
|
+
except ImportError:
|
|
19
|
+
CRYPTOGRAPHY_AVAILABLE = False
|
|
20
|
+
|
|
21
|
+
# Licensing Status Constants
|
|
22
|
+
STATUS_VALID = "VALID"
|
|
23
|
+
STATUS_EXPIRED = "EXPIRED"
|
|
24
|
+
STATUS_MISSING = "MISSING"
|
|
25
|
+
STATUS_INVALID = "INVALID"
|
|
26
|
+
STATUS_REQUIRES_SYNC = "REQUIRES_SYNC"
|
|
27
|
+
STATUS_UNKNOWN = "UNKNOWN"
|
|
28
|
+
|
|
29
|
+
class LicensingClient:
|
|
30
|
+
def __init__(self, server_url, license_file_path="license.lic", status_callback=None, max_offline_days=3):
|
|
31
|
+
"""
|
|
32
|
+
Initializes the Licensing Client.
|
|
33
|
+
|
|
34
|
+
:param server_url: Base URL of the Spring Boot Licensing Server (e.g. 'http://localhost:8081')
|
|
35
|
+
:param license_file_path: File path where the signed compact license key is stored
|
|
36
|
+
:param status_callback: Callable function triggered when the license status transitions
|
|
37
|
+
:param max_offline_days: Number of days the client can run offline before requiring a sync
|
|
38
|
+
"""
|
|
39
|
+
self.server_url = server_url.rstrip("/")
|
|
40
|
+
self.license_file_path = license_file_path
|
|
41
|
+
self.status_callback = status_callback
|
|
42
|
+
self.max_offline_days = max_offline_days
|
|
43
|
+
|
|
44
|
+
self.metadata_file_path = f"{os.path.splitext(self.license_file_path)[0]}_metadata.json"
|
|
45
|
+
|
|
46
|
+
self.current_status = STATUS_UNKNOWN
|
|
47
|
+
self.public_key_b64 = None
|
|
48
|
+
self.last_server_sync = 0.0
|
|
49
|
+
|
|
50
|
+
self._stop_event = threading.Event()
|
|
51
|
+
self._lock = threading.Lock()
|
|
52
|
+
|
|
53
|
+
# Load local cached metadata on initialization
|
|
54
|
+
self._load_local_metadata()
|
|
55
|
+
|
|
56
|
+
@staticmethod
|
|
57
|
+
def get_hardware_id():
|
|
58
|
+
"""
|
|
59
|
+
Generates a cross-platform, consistent, and unique Hardware ID for the host machine.
|
|
60
|
+
|
|
61
|
+
:return: String representing unique Hardware ID (e.g., 'HW-E8C2F1A039B4725E')
|
|
62
|
+
"""
|
|
63
|
+
node = uuid.getnode()
|
|
64
|
+
system = platform.system()
|
|
65
|
+
machine = platform.machine()
|
|
66
|
+
processor = platform.processor()
|
|
67
|
+
|
|
68
|
+
# Combine system parameters into a stable raw string
|
|
69
|
+
system_info = f"{node}-{system}-{machine}-{processor}"
|
|
70
|
+
sha_hash = hashlib.sha256(system_info.encode("utf-8")).hexdigest()
|
|
71
|
+
|
|
72
|
+
return f"HW-{sha_hash[:16].upper()}"
|
|
73
|
+
|
|
74
|
+
def activate_device(self, activation_key):
|
|
75
|
+
"""
|
|
76
|
+
[SYNCHRONOUS] Activates the device by binding the activation key placeholder
|
|
77
|
+
to the local machine's generated Hardware ID.
|
|
78
|
+
|
|
79
|
+
:param activation_key: The Base64 placeholder code issued by the admin.
|
|
80
|
+
:return: (bool success, str message)
|
|
81
|
+
"""
|
|
82
|
+
hw_id = self.get_hardware_id()
|
|
83
|
+
activation_url = f"{self.server_url}/api/v1/licenses/activate"
|
|
84
|
+
payload = {
|
|
85
|
+
"activationKey": activation_key.strip(),
|
|
86
|
+
"hardwareID": hw_id
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
# 1. Fetch public key first to ensure we can verify the signature
|
|
91
|
+
self._fetch_public_key()
|
|
92
|
+
|
|
93
|
+
# 2. Call activation endpoint
|
|
94
|
+
response = requests.post(activation_url, json=payload, timeout=8)
|
|
95
|
+
response_json = response.json()
|
|
96
|
+
|
|
97
|
+
if response.status_code == 200 and response_json.get("success"):
|
|
98
|
+
response_list = response_json.get("responseList", [])
|
|
99
|
+
if response_list:
|
|
100
|
+
license_data = response_list[0]
|
|
101
|
+
license_key = license_data.get("licenseKey")
|
|
102
|
+
|
|
103
|
+
if license_key:
|
|
104
|
+
# Save the compact signed license key locally
|
|
105
|
+
with open(self.license_file_path, "w", encoding="utf-8") as f:
|
|
106
|
+
f.write(license_key)
|
|
107
|
+
|
|
108
|
+
# Update metadata status
|
|
109
|
+
self.last_server_sync = time.time()
|
|
110
|
+
self._save_local_metadata()
|
|
111
|
+
|
|
112
|
+
# Recalculate status immediately
|
|
113
|
+
self._run_check()
|
|
114
|
+
return True, "Device bound and activated successfully!"
|
|
115
|
+
|
|
116
|
+
error_message = response_json.get("message", "Device activation failed.")
|
|
117
|
+
return False, error_message
|
|
118
|
+
|
|
119
|
+
except Exception as e:
|
|
120
|
+
return False, f"Activation request failed: {str(e)}"
|
|
121
|
+
|
|
122
|
+
def start_monitoring(self, interval_seconds=300):
|
|
123
|
+
"""
|
|
124
|
+
Spawns the periodic licensing verification loop on a background daemon thread.
|
|
125
|
+
|
|
126
|
+
:param interval_seconds: Background check interval in seconds (default is 5 minutes).
|
|
127
|
+
"""
|
|
128
|
+
if not CRYPTOGRAPHY_AVAILABLE:
|
|
129
|
+
raise ImportError(
|
|
130
|
+
"The 'cryptography' library is required to run the LicensingClient. "
|
|
131
|
+
"Please install it using: pip install cryptography"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
self._stop_event.clear()
|
|
135
|
+
monitor_thread = threading.Thread(
|
|
136
|
+
target=self._monitor_loop,
|
|
137
|
+
args=(interval_seconds,),
|
|
138
|
+
daemon=True
|
|
139
|
+
)
|
|
140
|
+
monitor_thread.start()
|
|
141
|
+
|
|
142
|
+
def stop_monitoring(self):
|
|
143
|
+
"""Stops the background verification loop cleanly."""
|
|
144
|
+
self._stop_event.set()
|
|
145
|
+
|
|
146
|
+
def _monitor_loop(self, interval):
|
|
147
|
+
"""Background thread main monitoring loop."""
|
|
148
|
+
# Perform immediate validation check on startup
|
|
149
|
+
self._run_check()
|
|
150
|
+
|
|
151
|
+
# Loop periodically until thread shutdown event is set
|
|
152
|
+
while not self._stop_event.is_set():
|
|
153
|
+
self._stop_event.wait(interval)
|
|
154
|
+
if not self._stop_event.is_set():
|
|
155
|
+
self._run_check()
|
|
156
|
+
|
|
157
|
+
def _run_check(self, force_online=False):
|
|
158
|
+
"""Runs the verification logic and triggers the developer callback on state transition."""
|
|
159
|
+
with self._lock:
|
|
160
|
+
new_status = self._perform_verification(force_online=force_online)
|
|
161
|
+
if new_status != self.current_status:
|
|
162
|
+
self.current_status = new_status
|
|
163
|
+
if self.status_callback:
|
|
164
|
+
try:
|
|
165
|
+
self.status_callback(new_status)
|
|
166
|
+
except Exception as e:
|
|
167
|
+
print(f"[LicensingClient] Error in developer status callback: {e}")
|
|
168
|
+
|
|
169
|
+
def _perform_verification(self, force_online=False):
|
|
170
|
+
"""
|
|
171
|
+
Performs full licensing licensing_verification:
|
|
172
|
+
1. Checks for file presence
|
|
173
|
+
2. Validates RSA cryptographic signature offline
|
|
174
|
+
3. Validates hardware mapping and expiry timeline offline
|
|
175
|
+
4. Calculates force sync-timeout (3 days)
|
|
176
|
+
5. Synchronizes with server online (every 24 hours or if requires sync)
|
|
177
|
+
"""
|
|
178
|
+
# 1. Check if license file exists
|
|
179
|
+
if not os.path.exists(self.license_file_path):
|
|
180
|
+
return STATUS_MISSING
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
# Read local license file
|
|
184
|
+
with open(self.license_file_path, "r", encoding="utf-8") as f:
|
|
185
|
+
license_key = f.read().strip()
|
|
186
|
+
|
|
187
|
+
parts = license_key.split(".")
|
|
188
|
+
if len(parts) != 2:
|
|
189
|
+
return STATUS_INVALID
|
|
190
|
+
|
|
191
|
+
readable_b64, signature_b64 = parts[0], parts[1]
|
|
192
|
+
|
|
193
|
+
# Decode the base64 readable payload to parse variables
|
|
194
|
+
try:
|
|
195
|
+
payload_str = base64.b64decode(readable_b64).decode("utf-8")
|
|
196
|
+
except Exception:
|
|
197
|
+
return STATUS_INVALID
|
|
198
|
+
|
|
199
|
+
# Parse payload variables (e.g. licenseId=X;startDate=Y;validDays=Z;hardwareId=W)
|
|
200
|
+
params = {}
|
|
201
|
+
for segment in payload_str.split(";"):
|
|
202
|
+
if "=" in segment:
|
|
203
|
+
k, v = segment.split("=", 1)
|
|
204
|
+
params[k] = v
|
|
205
|
+
|
|
206
|
+
required_keys = ["licenseId", "startDate", "validDays", "hardwareId"]
|
|
207
|
+
if not all(k in params for k in required_keys):
|
|
208
|
+
return STATUS_INVALID
|
|
209
|
+
|
|
210
|
+
# 2. Local Hardware Binding Check
|
|
211
|
+
local_hw_id = self.get_hardware_id()
|
|
212
|
+
if params["hardwareId"] != local_hw_id:
|
|
213
|
+
return STATUS_INVALID
|
|
214
|
+
|
|
215
|
+
# 3. Local Expiration Timeline Check
|
|
216
|
+
try:
|
|
217
|
+
start_date = datetime.strptime(params["startDate"], "%Y-%m-%d %H:%M:%S")
|
|
218
|
+
valid_days = int(params["validDays"])
|
|
219
|
+
expiration_date = start_date + timedelta(days=valid_days)
|
|
220
|
+
except Exception:
|
|
221
|
+
return STATUS_INVALID
|
|
222
|
+
|
|
223
|
+
if datetime.now() > expiration_date:
|
|
224
|
+
return STATUS_EXPIRED
|
|
225
|
+
|
|
226
|
+
# Ensure we have the public key loaded
|
|
227
|
+
if not self.public_key_b64:
|
|
228
|
+
# Try fetching from server or metadata cache
|
|
229
|
+
self._fetch_public_key()
|
|
230
|
+
if not self.public_key_b64:
|
|
231
|
+
return STATUS_INVALID
|
|
232
|
+
|
|
233
|
+
# 4. Cryptographic RSA Signature Offline Validation
|
|
234
|
+
signature_valid = self._verify_rsa_signature(readable_b64, signature_b64)
|
|
235
|
+
if not signature_valid:
|
|
236
|
+
return STATUS_INVALID
|
|
237
|
+
|
|
238
|
+
# 5. Online Verification Sync Checks (Scheduled 24h & Sync-Timeout 3d)
|
|
239
|
+
now = time.time()
|
|
240
|
+
elapsed_since_sync = now - self.last_server_sync
|
|
241
|
+
|
|
242
|
+
# 24 Hours in seconds = 86,400 seconds
|
|
243
|
+
sync_interval_elapsed = elapsed_since_sync >= 24 * 3600
|
|
244
|
+
|
|
245
|
+
# Perform online check if:
|
|
246
|
+
# - We are forcing an online check (e.g. manual verification click)
|
|
247
|
+
# - 24 hours have passed since the last successful sync
|
|
248
|
+
if force_online or sync_interval_elapsed:
|
|
249
|
+
sync_result = self._perform_online_sync(license_key)
|
|
250
|
+
if sync_result == "SUCCESS":
|
|
251
|
+
return STATUS_VALID
|
|
252
|
+
elif sync_result == "REJECTED":
|
|
253
|
+
# Actively rejected by the server! Immediately lock and invalidate client!
|
|
254
|
+
# Purge local files and metadata so local offline validation is permanently blocked
|
|
255
|
+
try:
|
|
256
|
+
if os.path.exists(self.license_file_path):
|
|
257
|
+
os.remove(self.license_file_path)
|
|
258
|
+
except Exception as e:
|
|
259
|
+
print(f"[LicensingClient] Failed to delete rejected license file: {e}")
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
if os.path.exists(self.metadata_file_path):
|
|
263
|
+
os.remove(self.metadata_file_path)
|
|
264
|
+
except Exception as e:
|
|
265
|
+
print(f"[LicensingClient] Failed to delete metadata file: {e}")
|
|
266
|
+
|
|
267
|
+
self.last_server_sync = 0.0
|
|
268
|
+
self.public_key_b64 = None
|
|
269
|
+
return STATUS_INVALID
|
|
270
|
+
# If sync_result is "OFFLINE", we continue to the offline fallback!
|
|
271
|
+
|
|
272
|
+
# 3 Days in seconds = 259,200 seconds
|
|
273
|
+
offline_limit_elapsed = elapsed_since_sync >= self.max_offline_days * 24 * 3600
|
|
274
|
+
if offline_limit_elapsed:
|
|
275
|
+
# We've been offline too long! Force sync status transition.
|
|
276
|
+
return STATUS_REQUIRES_SYNC
|
|
277
|
+
|
|
278
|
+
# Otherwise, the offline verification is completely valid!
|
|
279
|
+
return STATUS_VALID
|
|
280
|
+
|
|
281
|
+
except Exception:
|
|
282
|
+
return STATUS_INVALID
|
|
283
|
+
|
|
284
|
+
def _verify_rsa_signature(self, readable_b64, signature_b64):
|
|
285
|
+
"""Cryptographically verifies the SHA256withRSA signature offline using the cached public key."""
|
|
286
|
+
if not CRYPTOGRAPHY_AVAILABLE:
|
|
287
|
+
return False
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
public_key_bytes = base64.b64decode(self.public_key_b64)
|
|
291
|
+
public_key = load_der_public_key(public_key_bytes)
|
|
292
|
+
|
|
293
|
+
sig_bytes = base64.b64decode(signature_b64)
|
|
294
|
+
payload_bytes = readable_b64.encode("utf-8")
|
|
295
|
+
|
|
296
|
+
public_key.verify(
|
|
297
|
+
sig_bytes,
|
|
298
|
+
payload_bytes,
|
|
299
|
+
padding.PKCS1v15(),
|
|
300
|
+
hashes.SHA256()
|
|
301
|
+
)
|
|
302
|
+
return True
|
|
303
|
+
except Exception:
|
|
304
|
+
return False
|
|
305
|
+
|
|
306
|
+
def _perform_online_sync(self, license_key):
|
|
307
|
+
"""
|
|
308
|
+
Pings the online verify endpoint to synchronize license state.
|
|
309
|
+
Returns:
|
|
310
|
+
"SUCCESS" if verified and valid on the server.
|
|
311
|
+
"REJECTED" if server is online and actively rejected it (revoked/deactivated/deleted).
|
|
312
|
+
"OFFLINE" if the server is unreachable (temporary network downtime).
|
|
313
|
+
"""
|
|
314
|
+
verify_url = f"{self.server_url}/api/v1/licenses/verify"
|
|
315
|
+
payload = {"licenseKey": license_key}
|
|
316
|
+
|
|
317
|
+
try:
|
|
318
|
+
response = requests.post(verify_url, json=payload, timeout=5)
|
|
319
|
+
|
|
320
|
+
# Case A: Active Success
|
|
321
|
+
if response.status_code == 200:
|
|
322
|
+
try:
|
|
323
|
+
response_json = response.json()
|
|
324
|
+
if response_json.get("success"):
|
|
325
|
+
response_list = response_json.get("responseList", [])
|
|
326
|
+
if response_list:
|
|
327
|
+
server_license = response_list[0]
|
|
328
|
+
# Check if backend reports key is active and not disabled
|
|
329
|
+
if server_license.get("status") == "ACTIVE" and server_license.get("active") == 1:
|
|
330
|
+
# Update sync time and cache
|
|
331
|
+
self.last_server_sync = time.time()
|
|
332
|
+
self._save_local_metadata()
|
|
333
|
+
return "SUCCESS"
|
|
334
|
+
else:
|
|
335
|
+
# Server successfully responded, but license is not active/enabled (e.g. pending, deactivated, expired)
|
|
336
|
+
return "REJECTED"
|
|
337
|
+
except Exception:
|
|
338
|
+
pass
|
|
339
|
+
|
|
340
|
+
# Case B: Active Server Rejection
|
|
341
|
+
# If server actively returns 400 Bad Request or 404 Not Found (e.g. from IllegalArgumentException)
|
|
342
|
+
# or returned a valid JSON with success=False, it means the server processed the key and found it invalid.
|
|
343
|
+
if response.status_code == 400 or response.status_code == 404 or response.status_code == 403:
|
|
344
|
+
return "REJECTED"
|
|
345
|
+
|
|
346
|
+
try:
|
|
347
|
+
response_json = response.json()
|
|
348
|
+
if response_json.get("success") is False:
|
|
349
|
+
return "REJECTED"
|
|
350
|
+
except Exception:
|
|
351
|
+
pass
|
|
352
|
+
|
|
353
|
+
# If the HTTP status is between 400 and 499, treat as REJECTED
|
|
354
|
+
if 400 <= response.status_code < 500:
|
|
355
|
+
return "REJECTED"
|
|
356
|
+
|
|
357
|
+
# Case C: Offline or temporary Server Error
|
|
358
|
+
# Treating 5xx server errors as OFFLINE ensures transient backend issues don't lock out valid paying clients.
|
|
359
|
+
return "OFFLINE"
|
|
360
|
+
|
|
361
|
+
except requests.exceptions.RequestException:
|
|
362
|
+
# Server is unreachable (Connection error, timeout, DNS failure)
|
|
363
|
+
return "OFFLINE"
|
|
364
|
+
except Exception:
|
|
365
|
+
return "OFFLINE"
|
|
366
|
+
|
|
367
|
+
def _fetch_public_key(self):
|
|
368
|
+
"""Fetches the active public key from the Spring Boot server and saves it to local cache."""
|
|
369
|
+
public_key_url = f"{self.server_url}/api/v1/licenses/public-key"
|
|
370
|
+
try:
|
|
371
|
+
response = requests.get(public_key_url, timeout=4)
|
|
372
|
+
response_json = response.json()
|
|
373
|
+
if response.status_code == 200 and response_json.get("success"):
|
|
374
|
+
response_list = response_json.get("responseList", [])
|
|
375
|
+
if response_list:
|
|
376
|
+
self.public_key_b64 = response_list[0]
|
|
377
|
+
self._save_local_metadata()
|
|
378
|
+
except Exception:
|
|
379
|
+
# Fail silently to support local cached fallback
|
|
380
|
+
pass
|
|
381
|
+
|
|
382
|
+
def _load_local_metadata(self):
|
|
383
|
+
"""Loads cached public key and last sync timestamp from a secure metadata JSON file."""
|
|
384
|
+
if os.path.exists(self.metadata_file_path):
|
|
385
|
+
try:
|
|
386
|
+
with open(self.metadata_file_path, "r", encoding="utf-8") as f:
|
|
387
|
+
data = json.load(f)
|
|
388
|
+
self.public_key_b64 = data.get("public_key")
|
|
389
|
+
self.last_server_sync = data.get("last_server_sync", 0.0)
|
|
390
|
+
except Exception:
|
|
391
|
+
pass
|
|
392
|
+
|
|
393
|
+
def _save_local_metadata(self):
|
|
394
|
+
"""Saves current public key and last sync timestamp to local metadata cache."""
|
|
395
|
+
try:
|
|
396
|
+
data = {
|
|
397
|
+
"public_key": self.public_key_b64,
|
|
398
|
+
"last_server_sync": self.last_server_sync
|
|
399
|
+
}
|
|
400
|
+
with open(self.metadata_file_path, "w", encoding="utf-8") as f:
|
|
401
|
+
json.dump(data, f, indent=4)
|
|
402
|
+
except Exception:
|
|
403
|
+
pass
|