permitcore 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.
permitcore/__init__.py
ADDED
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PermitCore Python SDK — Official client for PermitCore license management.
|
|
3
|
+
|
|
4
|
+
Quick start:
|
|
5
|
+
from permitcore import PermitCoreClient
|
|
6
|
+
|
|
7
|
+
client = PermitCoreClient("https://your-instance.com")
|
|
8
|
+
result = client.validate("PERMIT-XXXX-XXXX-XXXX-XXXX")
|
|
9
|
+
|
|
10
|
+
if result.is_valid:
|
|
11
|
+
print(f"Valid! Product: {result.product_name}")
|
|
12
|
+
if result.has_feature("export"):
|
|
13
|
+
enable_export()
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import platform
|
|
21
|
+
import socket
|
|
22
|
+
import uuid
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
from typing import Dict, List, Optional
|
|
26
|
+
from urllib import request as urllib_request
|
|
27
|
+
from urllib.error import URLError, HTTPError
|
|
28
|
+
from urllib.parse import quote
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class OfflineVerificationUnavailable(ImportError):
|
|
32
|
+
"""
|
|
33
|
+
Raised when offline token verification is attempted but the optional
|
|
34
|
+
'cryptography' package is not installed. Only the offline-token features
|
|
35
|
+
(verify_offline_token / activate_offline / validate_offline) need it —
|
|
36
|
+
the rest of this SDK has zero external dependencies.
|
|
37
|
+
"""
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ── Data classes ────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class LicenseResult:
|
|
45
|
+
is_valid: bool
|
|
46
|
+
product_name: Optional[str] = None
|
|
47
|
+
remaining_activations: Optional[int] = None
|
|
48
|
+
expires_at: Optional[str] = None
|
|
49
|
+
message: Optional[str] = None
|
|
50
|
+
custom_fields: Optional[Dict[str, str]] = None
|
|
51
|
+
vendor_warning: Optional[str] = None
|
|
52
|
+
features: Optional[List[str]] = None
|
|
53
|
+
is_trial: bool = False
|
|
54
|
+
trial_days_remaining: Optional[int] = None
|
|
55
|
+
node_locked: bool = False
|
|
56
|
+
offline_grace_days: Optional[int] = None
|
|
57
|
+
min_version: Optional[str] = None
|
|
58
|
+
max_version: Optional[str] = None
|
|
59
|
+
is_offline: bool = False
|
|
60
|
+
offline_cache_token: Optional[str] = None
|
|
61
|
+
"""
|
|
62
|
+
ECDSA-signed pc_grace_v1 token (non-null only when the license has an offline grace
|
|
63
|
+
period configured and this call succeeded). The SDK caches this — not the rest of this
|
|
64
|
+
response — and verifies it locally before trusting a cached result on a later offline
|
|
65
|
+
call. See verify_grace_cache_token().
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def has_feature(self, feature: str) -> bool:
|
|
69
|
+
"""Returns True if the license includes the given feature flag (case-insensitive)."""
|
|
70
|
+
if not self.features:
|
|
71
|
+
return False
|
|
72
|
+
return any(f.lower() == feature.lower() for f in self.features)
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def _from_dict(cls, data: dict) -> "LicenseResult":
|
|
76
|
+
return cls(
|
|
77
|
+
is_valid=data.get("isValid", False),
|
|
78
|
+
product_name=data.get("productName"),
|
|
79
|
+
remaining_activations=data.get("remainingActivations"),
|
|
80
|
+
expires_at=data.get("expiresAt"),
|
|
81
|
+
message=data.get("message"),
|
|
82
|
+
custom_fields=data.get("customFields"),
|
|
83
|
+
vendor_warning=data.get("vendorWarning"),
|
|
84
|
+
features=data.get("features"),
|
|
85
|
+
is_trial=data.get("isTrial", False),
|
|
86
|
+
trial_days_remaining=data.get("trialDaysRemaining"),
|
|
87
|
+
node_locked=data.get("nodeLocked", False),
|
|
88
|
+
offline_grace_days=data.get("offlineGraceDays"),
|
|
89
|
+
min_version=data.get("minVersion"),
|
|
90
|
+
max_version=data.get("maxVersion"),
|
|
91
|
+
offline_cache_token=data.get("offlineCacheToken"),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class FloatingSession:
|
|
97
|
+
success: bool
|
|
98
|
+
session_token: Optional[str] = None
|
|
99
|
+
expires_at: Optional[str] = None
|
|
100
|
+
message: Optional[str] = None
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
def _from_dict(cls, data: dict) -> "FloatingSession":
|
|
104
|
+
return cls(
|
|
105
|
+
success=data.get("success", False),
|
|
106
|
+
session_token=data.get("sessionToken"),
|
|
107
|
+
expires_at=data.get("expiresAt"),
|
|
108
|
+
message=data.get("message"),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class OfflineTokenPayload:
|
|
114
|
+
"""Decoded payload of a `pc_offline_v1` offline activation token."""
|
|
115
|
+
version: int = 1
|
|
116
|
+
token_id: Optional[str] = None
|
|
117
|
+
tenant_slug: Optional[str] = None
|
|
118
|
+
kid: Optional[str] = None
|
|
119
|
+
tenant_id: Optional[str] = None
|
|
120
|
+
license_id: Optional[str] = None
|
|
121
|
+
license_key_hash: Optional[str] = None
|
|
122
|
+
device_id: Optional[str] = None
|
|
123
|
+
device_name: Optional[str] = None
|
|
124
|
+
product_name: Optional[str] = None
|
|
125
|
+
max_activations: Optional[int] = None
|
|
126
|
+
issued_at: Optional[str] = None
|
|
127
|
+
expires_at: Optional[str] = None
|
|
128
|
+
"""
|
|
129
|
+
kid: TenantSigningKey.Kid used to sign this token — null on tokens issued before key
|
|
130
|
+
versioning existed (see the "legacy_null_kid" shared test vector). Mirrors
|
|
131
|
+
GraceCachePayload.kid; only used for informational/key-selection purposes by callers
|
|
132
|
+
that manage multiple signing keys — verify_offline_token() itself is handed the public
|
|
133
|
+
key to verify against directly and does not look it up via this field.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
@classmethod
|
|
137
|
+
def _from_dict(cls, data: dict) -> "OfflineTokenPayload":
|
|
138
|
+
return cls(
|
|
139
|
+
version=data.get("version", 1),
|
|
140
|
+
token_id=data.get("tokenId"),
|
|
141
|
+
tenant_slug=data.get("tenantSlug"),
|
|
142
|
+
kid=data.get("kid"),
|
|
143
|
+
tenant_id=data.get("tenantId"),
|
|
144
|
+
license_id=data.get("licenseId"),
|
|
145
|
+
license_key_hash=data.get("licenseKeyHash"),
|
|
146
|
+
device_id=data.get("deviceId"),
|
|
147
|
+
device_name=data.get("deviceName"),
|
|
148
|
+
product_name=data.get("productName"),
|
|
149
|
+
max_activations=data.get("maxActivations"),
|
|
150
|
+
issued_at=data.get("issuedAt"),
|
|
151
|
+
expires_at=data.get("expiresAt"),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass
|
|
156
|
+
class OfflineTokenResult:
|
|
157
|
+
is_valid: bool
|
|
158
|
+
message: str = ""
|
|
159
|
+
payload: Optional[OfflineTokenPayload] = None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass
|
|
163
|
+
class GraceCachePayload:
|
|
164
|
+
"""Decoded, verified payload of a `pc_grace_v1` offline grace-cache token."""
|
|
165
|
+
version: int = 1
|
|
166
|
+
tenant_slug: Optional[str] = None
|
|
167
|
+
kid: Optional[str] = None
|
|
168
|
+
license_key_hash: Optional[str] = None
|
|
169
|
+
device_id: Optional[str] = None
|
|
170
|
+
is_valid: bool = False
|
|
171
|
+
product_name: Optional[str] = None
|
|
172
|
+
features: Optional[List[str]] = None
|
|
173
|
+
remaining_activations: Optional[int] = None
|
|
174
|
+
expires_at: Optional[str] = None
|
|
175
|
+
issued_at: Optional[str] = None
|
|
176
|
+
valid_until: Optional[str] = None
|
|
177
|
+
|
|
178
|
+
@classmethod
|
|
179
|
+
def _from_dict(cls, data: dict) -> "GraceCachePayload":
|
|
180
|
+
return cls(
|
|
181
|
+
version=data.get("version", 1),
|
|
182
|
+
tenant_slug=data.get("tenantSlug"),
|
|
183
|
+
kid=data.get("kid"),
|
|
184
|
+
license_key_hash=data.get("licenseKeyHash"),
|
|
185
|
+
device_id=data.get("deviceId"),
|
|
186
|
+
is_valid=data.get("isValid", False),
|
|
187
|
+
product_name=data.get("productName"),
|
|
188
|
+
features=data.get("features"),
|
|
189
|
+
remaining_activations=data.get("remainingActivations"),
|
|
190
|
+
expires_at=data.get("expiresAt"),
|
|
191
|
+
issued_at=data.get("issuedAt"),
|
|
192
|
+
valid_until=data.get("validUntil"),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@dataclass
|
|
197
|
+
class GraceCacheResult:
|
|
198
|
+
is_valid: bool
|
|
199
|
+
message: str = ""
|
|
200
|
+
payload: Optional[GraceCachePayload] = None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ── Client ──────────────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
class PermitCoreClient:
|
|
206
|
+
"""
|
|
207
|
+
Official PermitCore Python SDK client.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
base_url: Your PermitCore API base URL, e.g. "https://api.yourapp.com"
|
|
211
|
+
enable_offline_cache: Cache responses for offline grace period (default True)
|
|
212
|
+
timeout: HTTP request timeout in seconds (default 5)
|
|
213
|
+
"""
|
|
214
|
+
|
|
215
|
+
def __init__(self, base_url: str, enable_offline_cache: bool = True, timeout: int = 5):
|
|
216
|
+
self._base = base_url.rstrip("/")
|
|
217
|
+
self._cache = enable_offline_cache
|
|
218
|
+
self._timeout = timeout
|
|
219
|
+
|
|
220
|
+
# ── Validate ─────────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
def validate(self, license_key: str, version: Optional[str] = None) -> LicenseResult:
|
|
223
|
+
"""
|
|
224
|
+
Validates a license key. Does NOT consume an activation slot.
|
|
225
|
+
version: Optional current software version — lets the server enforce
|
|
226
|
+
minVersion/maxVersion restrictions on the license.
|
|
227
|
+
Falls back to local cache when server is unreachable.
|
|
228
|
+
"""
|
|
229
|
+
payload = {"licenseKey": license_key}
|
|
230
|
+
if version:
|
|
231
|
+
payload["version"] = version
|
|
232
|
+
try:
|
|
233
|
+
data = self._post("api/v1/validate", payload)
|
|
234
|
+
result = LicenseResult._from_dict(data)
|
|
235
|
+
if result.is_valid:
|
|
236
|
+
self._save_cache(license_key, result)
|
|
237
|
+
return result
|
|
238
|
+
except (URLError, OSError, TimeoutError):
|
|
239
|
+
cached = self._load_cache(license_key)
|
|
240
|
+
if cached:
|
|
241
|
+
return cached
|
|
242
|
+
return LicenseResult(is_valid=False, message="Cannot reach license server.", is_offline=True)
|
|
243
|
+
|
|
244
|
+
# ── Activate ─────────────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
def activate(self, license_key: str, device_id: Optional[str] = None,
|
|
247
|
+
device_name: Optional[str] = None, version: Optional[str] = None) -> LicenseResult:
|
|
248
|
+
"""
|
|
249
|
+
Validates AND activates the key on this device. Call only once per installation.
|
|
250
|
+
device_id: Optional — auto-generated HWID is used when omitted.
|
|
251
|
+
version: Optional current software version — lets the server enforce
|
|
252
|
+
minVersion/maxVersion restrictions on the license.
|
|
253
|
+
"""
|
|
254
|
+
hwid = device_id or self.get_hardware_id()
|
|
255
|
+
try:
|
|
256
|
+
# [S-Nonce] Fetch a single-use nonce first (replay-attack protection)
|
|
257
|
+
nonce_data = self._get("api/v1/nonce")
|
|
258
|
+
nonce = nonce_data["nonce"]
|
|
259
|
+
payload = {"licenseKey": license_key, "deviceId": hwid, "nonce": nonce}
|
|
260
|
+
if device_name:
|
|
261
|
+
payload["deviceName"] = device_name
|
|
262
|
+
if version:
|
|
263
|
+
payload["version"] = version
|
|
264
|
+
data = self._post("api/v1/activate", payload)
|
|
265
|
+
result = LicenseResult._from_dict(data)
|
|
266
|
+
if result.is_valid:
|
|
267
|
+
self._save_cache(license_key, result)
|
|
268
|
+
return result
|
|
269
|
+
except (URLError, OSError, TimeoutError):
|
|
270
|
+
# [S-Continuity] If this device already activated successfully before (e.g. an app
|
|
271
|
+
# that re-runs activate() on every launch, or a reinstall that kept the cache file),
|
|
272
|
+
# fall back to that cached result instead of failing outright.
|
|
273
|
+
cached = self._load_cache(license_key)
|
|
274
|
+
if cached:
|
|
275
|
+
return cached
|
|
276
|
+
return LicenseResult(is_valid=False, message="Cannot reach license server.", is_offline=True)
|
|
277
|
+
|
|
278
|
+
# ── Meter ────────────────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
def meter(self, license_key: str, event_name: str, quantity: int = 1,
|
|
281
|
+
meta: Optional[dict] = None) -> bool:
|
|
282
|
+
"""
|
|
283
|
+
Records a usage event for metered billing.
|
|
284
|
+
Returns True if the event was recorded on the server, False on any failure
|
|
285
|
+
(network error, or the server rejecting the event).
|
|
286
|
+
"""
|
|
287
|
+
payload = {"licenseKey": license_key, "eventName": event_name, "quantity": quantity}
|
|
288
|
+
if meta:
|
|
289
|
+
payload["meta"] = meta
|
|
290
|
+
try:
|
|
291
|
+
data = self._post("api/v1/meter", payload)
|
|
292
|
+
return bool(data.get("recorded", False))
|
|
293
|
+
except (URLError, OSError, TimeoutError):
|
|
294
|
+
return False
|
|
295
|
+
|
|
296
|
+
# ── Floating ─────────────────────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
def checkout(self, license_key: str, device_id: Optional[str] = None,
|
|
299
|
+
device_name: Optional[str] = None) -> FloatingSession:
|
|
300
|
+
"""Checks out a concurrent seat for a floating license."""
|
|
301
|
+
hwid = device_id or self.get_hardware_id()
|
|
302
|
+
payload = {"licenseKey": license_key, "deviceId": hwid}
|
|
303
|
+
if device_name:
|
|
304
|
+
payload["deviceName"] = device_name
|
|
305
|
+
data = self._post("api/v1/float/checkout", payload)
|
|
306
|
+
return FloatingSession._from_dict(data)
|
|
307
|
+
|
|
308
|
+
def heartbeat(self, session_token: str) -> FloatingSession:
|
|
309
|
+
"""Keeps a floating session alive. Call every 4–5 minutes."""
|
|
310
|
+
# [S-Float] Session token in the POST body — never the URL path — matches
|
|
311
|
+
# FloatingController.Heartbeat's FloatingTokenRequest exactly.
|
|
312
|
+
data = self._post("api/v1/float/heartbeat", {"sessionToken": session_token})
|
|
313
|
+
return FloatingSession._from_dict(data)
|
|
314
|
+
|
|
315
|
+
def checkin(self, session_token: str) -> None:
|
|
316
|
+
"""Releases a floating seat."""
|
|
317
|
+
# [S-Float] Session token in the POST body — never the URL path — matches
|
|
318
|
+
# FloatingController.Checkin's FloatingTokenRequest exactly (also: it's a POST, not DELETE).
|
|
319
|
+
req = urllib_request.Request(
|
|
320
|
+
f"{self._base}/api/v1/float/checkin",
|
|
321
|
+
data=json.dumps({"sessionToken": session_token}).encode(),
|
|
322
|
+
method="POST",
|
|
323
|
+
)
|
|
324
|
+
req.add_header("Content-Type", "application/json")
|
|
325
|
+
req.add_header("User-Agent", "PermitCore-Python/1.0")
|
|
326
|
+
try:
|
|
327
|
+
with urllib_request.urlopen(req, timeout=self._timeout):
|
|
328
|
+
pass
|
|
329
|
+
except (URLError, OSError):
|
|
330
|
+
pass
|
|
331
|
+
|
|
332
|
+
# ── Offline token verification (pc_offline_v1, ECDSA P-256 / IEEE P1363) ───────────
|
|
333
|
+
#
|
|
334
|
+
# Requires the optional 'cryptography' package: pip install cryptography
|
|
335
|
+
# (or `pip install permitcore[offline]` if installed via extras). Lazy-imported so the
|
|
336
|
+
# rest of this SDK stays zero-dependency. Mirrors
|
|
337
|
+
# PermitCore.Infrastructure.Services.OfflineActivationService.Verify byte-for-byte.
|
|
338
|
+
|
|
339
|
+
@staticmethod
|
|
340
|
+
def verify_offline_token(token: str, public_key_base64: str) -> "OfflineTokenResult":
|
|
341
|
+
"""
|
|
342
|
+
Verifies an offline activation token entirely locally — no network call.
|
|
343
|
+
This is the primitive every other offline_* method builds on.
|
|
344
|
+
"""
|
|
345
|
+
parts = token.split(".") if token else []
|
|
346
|
+
if len(parts) != 3 or parts[0] != "pc_offline_v1":
|
|
347
|
+
return OfflineTokenResult(is_valid=False, message="Malformed token.")
|
|
348
|
+
|
|
349
|
+
ec, ec_utils, load_der_public_key, hashes, InvalidSignature = _require_cryptography()
|
|
350
|
+
|
|
351
|
+
try:
|
|
352
|
+
payload_bytes = parts[1].encode("utf-8")
|
|
353
|
+
sig = _b64url_decode(parts[2])
|
|
354
|
+
if len(sig) != 64:
|
|
355
|
+
return OfflineTokenResult(is_valid=False, message="Invalid signature.")
|
|
356
|
+
|
|
357
|
+
r = int.from_bytes(sig[:32], "big")
|
|
358
|
+
s = int.from_bytes(sig[32:], "big")
|
|
359
|
+
der_sig = ec_utils.encode_dss_signature(r, s)
|
|
360
|
+
|
|
361
|
+
public_key = load_der_public_key(base64.b64decode(public_key_base64))
|
|
362
|
+
|
|
363
|
+
try:
|
|
364
|
+
public_key.verify(der_sig, payload_bytes, ec.ECDSA(hashes.SHA256()))
|
|
365
|
+
except InvalidSignature:
|
|
366
|
+
return OfflineTokenResult(is_valid=False, message="Invalid signature.")
|
|
367
|
+
|
|
368
|
+
json_bytes = _b64url_decode(parts[1])
|
|
369
|
+
payload = OfflineTokenPayload._from_dict(json.loads(json_bytes))
|
|
370
|
+
|
|
371
|
+
if payload.expires_at and _parse_iso8601(payload.expires_at) < datetime.now(timezone.utc):
|
|
372
|
+
return OfflineTokenResult(is_valid=False, message="Token expired.", payload=payload)
|
|
373
|
+
|
|
374
|
+
return OfflineTokenResult(is_valid=True, message="Valid.", payload=payload)
|
|
375
|
+
except OfflineVerificationUnavailable:
|
|
376
|
+
raise
|
|
377
|
+
except Exception:
|
|
378
|
+
return OfflineTokenResult(is_valid=False, message="Invalid or corrupt token.")
|
|
379
|
+
|
|
380
|
+
# ── Grace-cache token verification (pc_grace_v1, ECDSA P-256 / IEEE P1363) ─────────
|
|
381
|
+
# Reference implementation — matches
|
|
382
|
+
# PermitCore.Infrastructure.Services.OfflineActivationService.VerifyGraceCache
|
|
383
|
+
# byte-for-byte, same relationship verify_offline_token above has to its server-side
|
|
384
|
+
# counterpart. Used by _save_cache/_load_cache below to replace the old unsigned
|
|
385
|
+
# offline-grace cache with one an attacker can't hand-edit.
|
|
386
|
+
|
|
387
|
+
@staticmethod
|
|
388
|
+
def verify_grace_cache_token(token: str, public_key_base64: str) -> "GraceCacheResult":
|
|
389
|
+
"""
|
|
390
|
+
Verifies a pc_grace_v1 offline grace-cache token entirely locally — no network call.
|
|
391
|
+
Exposed publicly so a custom integration (not using the built-in cache) can implement
|
|
392
|
+
its own caching around the same verified primitive.
|
|
393
|
+
"""
|
|
394
|
+
parts = token.split(".") if token else []
|
|
395
|
+
if len(parts) != 3 or parts[0] != "pc_grace_v1":
|
|
396
|
+
return GraceCacheResult(is_valid=False, message="Malformed token.")
|
|
397
|
+
|
|
398
|
+
ec, ec_utils, load_der_public_key, hashes, InvalidSignature = _require_cryptography()
|
|
399
|
+
|
|
400
|
+
try:
|
|
401
|
+
payload_bytes = parts[1].encode("utf-8")
|
|
402
|
+
sig = _b64url_decode(parts[2])
|
|
403
|
+
if len(sig) != 64:
|
|
404
|
+
return GraceCacheResult(is_valid=False, message="Invalid signature.")
|
|
405
|
+
|
|
406
|
+
r = int.from_bytes(sig[:32], "big")
|
|
407
|
+
s = int.from_bytes(sig[32:], "big")
|
|
408
|
+
der_sig = ec_utils.encode_dss_signature(r, s)
|
|
409
|
+
|
|
410
|
+
public_key = load_der_public_key(base64.b64decode(public_key_base64))
|
|
411
|
+
|
|
412
|
+
try:
|
|
413
|
+
public_key.verify(der_sig, payload_bytes, ec.ECDSA(hashes.SHA256()))
|
|
414
|
+
except InvalidSignature:
|
|
415
|
+
return GraceCacheResult(is_valid=False, message="Invalid signature.")
|
|
416
|
+
|
|
417
|
+
json_bytes = _b64url_decode(parts[1])
|
|
418
|
+
payload = GraceCachePayload._from_dict(json.loads(json_bytes))
|
|
419
|
+
|
|
420
|
+
if payload.valid_until and _parse_iso8601(payload.valid_until) < datetime.now(timezone.utc):
|
|
421
|
+
return GraceCacheResult(is_valid=False, message="Grace period expired.", payload=payload)
|
|
422
|
+
|
|
423
|
+
return GraceCacheResult(is_valid=True, message="Valid.", payload=payload)
|
|
424
|
+
except OfflineVerificationUnavailable:
|
|
425
|
+
raise
|
|
426
|
+
except Exception:
|
|
427
|
+
return GraceCacheResult(is_valid=False, message="Invalid or corrupt token.")
|
|
428
|
+
|
|
429
|
+
@staticmethod
|
|
430
|
+
def activate_offline(token: str, public_key_base64: str, device_id: str) -> "OfflineTokenResult":
|
|
431
|
+
"""
|
|
432
|
+
Verifies an offline token, additionally checks it was issued for this device, and —
|
|
433
|
+
on success — persists the verified payload to local disk so validate_offline() can be
|
|
434
|
+
called later without needing the original token again.
|
|
435
|
+
"""
|
|
436
|
+
result = PermitCoreClient.verify_offline_token(token, public_key_base64)
|
|
437
|
+
if not result.is_valid or result.payload is None:
|
|
438
|
+
return result
|
|
439
|
+
|
|
440
|
+
if (result.payload.device_id or "").lower() != (device_id or "").lower():
|
|
441
|
+
return OfflineTokenResult(
|
|
442
|
+
is_valid=False,
|
|
443
|
+
message="Token was issued for a different device.",
|
|
444
|
+
payload=result.payload,
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
try:
|
|
448
|
+
with open(_offline_cache_path(device_id), "w") as f:
|
|
449
|
+
json.dump(result.payload.__dict__, f)
|
|
450
|
+
except OSError:
|
|
451
|
+
pass # cache failure must never block a successful verification
|
|
452
|
+
|
|
453
|
+
return result
|
|
454
|
+
|
|
455
|
+
@staticmethod
|
|
456
|
+
def validate_offline(device_id: str) -> "OfflineTokenResult":
|
|
457
|
+
"""
|
|
458
|
+
Reads the locally persisted offline-activation result (from a prior activate_offline()
|
|
459
|
+
call) and checks it's still within its validity window. No network call, no token
|
|
460
|
+
needed — call this on every app launch once already offline-activated.
|
|
461
|
+
"""
|
|
462
|
+
try:
|
|
463
|
+
path = _offline_cache_path(device_id)
|
|
464
|
+
if not os.path.exists(path):
|
|
465
|
+
return OfflineTokenResult(is_valid=False, message="No local offline activation found.")
|
|
466
|
+
|
|
467
|
+
with open(path) as f:
|
|
468
|
+
data = json.load(f)
|
|
469
|
+
payload = OfflineTokenPayload(**data)
|
|
470
|
+
|
|
471
|
+
if (payload.device_id or "").lower() != (device_id or "").lower():
|
|
472
|
+
return OfflineTokenResult(is_valid=False, message="Device mismatch.", payload=payload)
|
|
473
|
+
|
|
474
|
+
if payload.expires_at and _parse_iso8601(payload.expires_at) < datetime.now(timezone.utc):
|
|
475
|
+
return OfflineTokenResult(is_valid=False, message="Offline activation expired.", payload=payload)
|
|
476
|
+
|
|
477
|
+
return OfflineTokenResult(is_valid=True, message="Valid (offline).", payload=payload)
|
|
478
|
+
except (OSError, KeyError, TypeError, json.JSONDecodeError):
|
|
479
|
+
return OfflineTokenResult(is_valid=False, message="Corrupt local offline activation.")
|
|
480
|
+
|
|
481
|
+
def verify_offline_online(self, token: str) -> "OfflineTokenResult":
|
|
482
|
+
"""
|
|
483
|
+
Optional online check: asks the server to verify the token AND check its revocation
|
|
484
|
+
status. Requires network. Use verify_offline_token() for pure offline verification —
|
|
485
|
+
this method does not need the 'cryptography' package, the server does the crypto.
|
|
486
|
+
"""
|
|
487
|
+
try:
|
|
488
|
+
data = self._post("api/v1/offline/verify", {"token": token})
|
|
489
|
+
return OfflineTokenResult(
|
|
490
|
+
is_valid=bool(data.get("isValid", False)),
|
|
491
|
+
message=data.get("message") or "",
|
|
492
|
+
)
|
|
493
|
+
except (URLError, OSError, TimeoutError):
|
|
494
|
+
return OfflineTokenResult(is_valid=False, message="Cannot reach license server.")
|
|
495
|
+
|
|
496
|
+
# ── HWID ─────────────────────────────────────────────────────────────
|
|
497
|
+
|
|
498
|
+
@staticmethod
|
|
499
|
+
def get_hardware_id() -> str:
|
|
500
|
+
"""Generates a stable hardware fingerprint (SHA-256 of machine identifiers)."""
|
|
501
|
+
components = [
|
|
502
|
+
socket.gethostname(),
|
|
503
|
+
platform.system(),
|
|
504
|
+
platform.machine(),
|
|
505
|
+
str(os.cpu_count() or 1),
|
|
506
|
+
_get_or_create_seed(),
|
|
507
|
+
]
|
|
508
|
+
combined = "|".join(c for c in components if c)
|
|
509
|
+
return hashlib.sha256(combined.encode()).hexdigest()
|
|
510
|
+
|
|
511
|
+
# ── Cache ─────────────────────────────────────────────────────────────
|
|
512
|
+
|
|
513
|
+
def _save_cache(self, license_key: str, result: LicenseResult) -> None:
|
|
514
|
+
# [S-Grace1] Cache the SIGNED token, not the raw response — the server only issues
|
|
515
|
+
# offline_cache_token when a grace period is configured, so a missing token here
|
|
516
|
+
# already means "nothing to cache," same as the old offline_grace_days check used to.
|
|
517
|
+
if not self._cache or not result.offline_cache_token:
|
|
518
|
+
return
|
|
519
|
+
try:
|
|
520
|
+
tenant_slug = _extract_unverified_tenant_slug(result.offline_cache_token)
|
|
521
|
+
if not tenant_slug:
|
|
522
|
+
return
|
|
523
|
+
|
|
524
|
+
pub_key_data = self._get(f"api/v1/{quote(tenant_slug, safe='')}/public-key")
|
|
525
|
+
public_key = pub_key_data.get("publicKey")
|
|
526
|
+
if not public_key:
|
|
527
|
+
return
|
|
528
|
+
|
|
529
|
+
# Verify before persisting anything — never cache a token this SDK can't itself
|
|
530
|
+
# verify later; that would just recreate the old "trust an opaque file" problem.
|
|
531
|
+
check = self.verify_grace_cache_token(result.offline_cache_token, public_key)
|
|
532
|
+
if not check.is_valid:
|
|
533
|
+
return
|
|
534
|
+
|
|
535
|
+
entry = {"token": result.offline_cache_token, "public_key": public_key}
|
|
536
|
+
with open(_cache_path(license_key), "w") as f:
|
|
537
|
+
json.dump(entry, f)
|
|
538
|
+
except Exception:
|
|
539
|
+
pass # cache failure must never block the normal online flow
|
|
540
|
+
|
|
541
|
+
def _load_cache(self, license_key: str) -> Optional[LicenseResult]:
|
|
542
|
+
if not self._cache:
|
|
543
|
+
return None
|
|
544
|
+
try:
|
|
545
|
+
with open(_cache_path(license_key)) as f:
|
|
546
|
+
entry = json.load(f)
|
|
547
|
+
token = entry.get("token")
|
|
548
|
+
public_key = entry.get("public_key")
|
|
549
|
+
if not token or not public_key:
|
|
550
|
+
return None
|
|
551
|
+
|
|
552
|
+
# No network call here — verification uses only the public key persisted
|
|
553
|
+
# alongside the token at save time. This is the entire point: a hand-edited
|
|
554
|
+
# cache file (or one copied to another machine) fails ECDSA verification
|
|
555
|
+
# instead of silently working.
|
|
556
|
+
check = self.verify_grace_cache_token(token, public_key)
|
|
557
|
+
if not check.is_valid or check.payload is None:
|
|
558
|
+
return None
|
|
559
|
+
|
|
560
|
+
p = check.payload
|
|
561
|
+
valid_until_display = p.valid_until
|
|
562
|
+
try:
|
|
563
|
+
if p.valid_until:
|
|
564
|
+
valid_until_display = _parse_iso8601(p.valid_until).strftime("%d %b %Y")
|
|
565
|
+
except Exception:
|
|
566
|
+
pass
|
|
567
|
+
|
|
568
|
+
return LicenseResult(
|
|
569
|
+
is_valid=p.is_valid,
|
|
570
|
+
product_name=p.product_name,
|
|
571
|
+
remaining_activations=p.remaining_activations,
|
|
572
|
+
expires_at=p.expires_at,
|
|
573
|
+
features=p.features,
|
|
574
|
+
is_offline=True,
|
|
575
|
+
message=f"Offline mode — valid until {valid_until_display} (cryptographically verified)",
|
|
576
|
+
)
|
|
577
|
+
except Exception:
|
|
578
|
+
return None
|
|
579
|
+
|
|
580
|
+
# ── HTTP helpers ──────────────────────────────────────────────────────
|
|
581
|
+
|
|
582
|
+
def _get(self, path: str) -> dict:
|
|
583
|
+
req = urllib_request.Request(f"{self._base}/{path}")
|
|
584
|
+
req.add_header("User-Agent", "PermitCore-Python/1.0")
|
|
585
|
+
with urllib_request.urlopen(req, timeout=self._timeout) as resp:
|
|
586
|
+
return json.loads(resp.read())
|
|
587
|
+
|
|
588
|
+
def _post(self, path: str, payload: dict) -> dict:
|
|
589
|
+
data = json.dumps(payload).encode()
|
|
590
|
+
req = urllib_request.Request(f"{self._base}/{path}", data=data, method="POST")
|
|
591
|
+
req.add_header("Content-Type", "application/json")
|
|
592
|
+
req.add_header("User-Agent", "PermitCore-Python/1.0")
|
|
593
|
+
with urllib_request.urlopen(req, timeout=self._timeout) as resp:
|
|
594
|
+
return json.loads(resp.read())
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
# ── Helpers ────────────────────────────────────────────────────────────────
|
|
598
|
+
|
|
599
|
+
def _cache_path(license_key: str) -> str:
|
|
600
|
+
h = hashlib.sha256(license_key.encode()).hexdigest()[:16]
|
|
601
|
+
return os.path.join(os.path.expanduser("~"), f".permitcore_cache_{h}")
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _offline_cache_path(device_id: str) -> str:
|
|
605
|
+
h = hashlib.sha256((device_id or "").encode()).hexdigest()[:16]
|
|
606
|
+
return os.path.join(os.path.expanduser("~"), f".permitcore_offline_{h}")
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _b64url_decode(s: str) -> bytes:
|
|
610
|
+
padding = "=" * (-len(s) % 4)
|
|
611
|
+
return base64.urlsafe_b64decode(s + padding)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _extract_unverified_tenant_slug(token: str) -> Optional[str]:
|
|
615
|
+
"""
|
|
616
|
+
Reads only the `tenantSlug` field out of a pc_grace_v1 token's payload, WITHOUT
|
|
617
|
+
verifying the signature — safe to do because it's only used to pick which tenant's
|
|
618
|
+
public-key endpoint to fetch. PermitCoreClient.verify_grace_cache_token() is what
|
|
619
|
+
actually establishes trust before anything gets persisted to disk.
|
|
620
|
+
"""
|
|
621
|
+
if not token:
|
|
622
|
+
return None
|
|
623
|
+
parts = token.split(".")
|
|
624
|
+
if len(parts) != 3 or parts[0] != "pc_grace_v1":
|
|
625
|
+
return None
|
|
626
|
+
try:
|
|
627
|
+
data = json.loads(_b64url_decode(parts[1]))
|
|
628
|
+
return data.get("tenantSlug")
|
|
629
|
+
except Exception:
|
|
630
|
+
return None
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _parse_iso8601(s: str) -> datetime:
|
|
634
|
+
if s.endswith("Z"):
|
|
635
|
+
s = s[:-1] + "+00:00"
|
|
636
|
+
dt = datetime.fromisoformat(s)
|
|
637
|
+
if dt.tzinfo is None:
|
|
638
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
639
|
+
return dt
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def _require_cryptography():
|
|
643
|
+
"""
|
|
644
|
+
Lazy-imports the optional 'cryptography' package. Only called from inside the
|
|
645
|
+
offline-token verification functions — the rest of this SDK must import cleanly
|
|
646
|
+
without 'cryptography' installed.
|
|
647
|
+
"""
|
|
648
|
+
try:
|
|
649
|
+
from cryptography.exceptions import InvalidSignature
|
|
650
|
+
from cryptography.hazmat.primitives import hashes
|
|
651
|
+
from cryptography.hazmat.primitives.asymmetric import ec, utils as ec_utils
|
|
652
|
+
from cryptography.hazmat.primitives.serialization import load_der_public_key
|
|
653
|
+
except ImportError as e:
|
|
654
|
+
raise OfflineVerificationUnavailable(
|
|
655
|
+
"Offline token verification requires the 'cryptography' package. "
|
|
656
|
+
"Install it with: pip install cryptography"
|
|
657
|
+
) from e
|
|
658
|
+
return ec, ec_utils, load_der_public_key, hashes, InvalidSignature
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _get_or_create_seed() -> str:
|
|
662
|
+
seed_path = os.path.join(os.path.expanduser("~"), ".permitcore_seed")
|
|
663
|
+
try:
|
|
664
|
+
if not os.path.exists(seed_path):
|
|
665
|
+
with open(seed_path, "w") as f:
|
|
666
|
+
f.write(str(uuid.uuid4()))
|
|
667
|
+
with open(seed_path) as f:
|
|
668
|
+
return f.read().strip()
|
|
669
|
+
except OSError:
|
|
670
|
+
return ""
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: permitcore
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official Python SDK for PermitCore license management
|
|
5
|
+
Author-email: PermitCore <sdk@permitcore.dev>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://permitcore.dev
|
|
8
|
+
Project-URL: Repository, https://github.com/permitCore-spec/PermitCore/tree/main/SDKs/python
|
|
9
|
+
Keywords: license,licensing,sdk,permitcore,activation
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Requires-Python: >=3.8
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
Provides-Extra: offline
|
|
23
|
+
Requires-Dist: cryptography>=41; extra == "offline"
|
|
24
|
+
Provides-Extra: test
|
|
25
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
26
|
+
Requires-Dist: cryptography>=41; extra == "test"
|
|
27
|
+
|
|
28
|
+
# PermitCore Python SDK
|
|
29
|
+
|
|
30
|
+
Official Python client for [PermitCore](https://permitcore.dev) license management.
|
|
31
|
+
|
|
32
|
+
**Requirements:** Python 3.8+, zero required dependencies. The optional `cryptography` package
|
|
33
|
+
(`pip install permitcore[offline]`) is only needed for offline license token verification.
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install permitcore
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Or, for offline license token verification support:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install permitcore[offline]
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Quick start
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from permitcore import PermitCoreClient
|
|
55
|
+
|
|
56
|
+
client = PermitCoreClient("https://your-instance.com")
|
|
57
|
+
result = client.validate("PERMIT-XXXX-XXXX-XXXX-XXXX")
|
|
58
|
+
|
|
59
|
+
if result.is_valid:
|
|
60
|
+
print(f"Valid! Product: {result.product_name}")
|
|
61
|
+
if result.has_feature("export"):
|
|
62
|
+
enable_export()
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Validate
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
result = client.validate(license_key, version="2.3.1") # version is optional
|
|
71
|
+
|
|
72
|
+
# result.is_valid bool
|
|
73
|
+
# result.product_name Optional[str]
|
|
74
|
+
# result.remaining_activations Optional[int]
|
|
75
|
+
# result.expires_at Optional[str] (ISO 8601)
|
|
76
|
+
# result.features Optional[List[str]]
|
|
77
|
+
# result.custom_fields Optional[Dict[str, str]]
|
|
78
|
+
# result.is_trial bool
|
|
79
|
+
# result.trial_days_remaining Optional[int]
|
|
80
|
+
# result.node_locked bool
|
|
81
|
+
# result.offline_grace_days Optional[int]
|
|
82
|
+
# result.min_version Optional[str]
|
|
83
|
+
# result.max_version Optional[str]
|
|
84
|
+
# result.vendor_warning Optional[str]
|
|
85
|
+
# result.message Optional[str]
|
|
86
|
+
# result.is_offline bool (true when served from local cache)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`validate()` never consumes an activation slot. It falls back to the local disk cache when the
|
|
90
|
+
server is unreachable, as long as the license has `offline_grace_days` configured. Passing
|
|
91
|
+
`version` lets the server enforce `min_version`/`max_version` restrictions on the license.
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Activate
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
result = client.activate(
|
|
99
|
+
license_key,
|
|
100
|
+
device_id=None, # auto-generated HWID when omitted
|
|
101
|
+
device_name="Production Server #1",
|
|
102
|
+
version="2.3.1", # optional
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
if not result.is_valid:
|
|
106
|
+
raise RuntimeError(f"Activation failed: {result.message}")
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Call `activate()` **once** per installation. Use `validate()` on every subsequent launch.
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Meter (usage events)
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
# Record a single API call
|
|
117
|
+
recorded = client.meter(license_key, "api_call")
|
|
118
|
+
|
|
119
|
+
# Record bulk usage with metadata
|
|
120
|
+
recorded = client.meter(license_key, "export", quantity=5, meta={"format": "pdf", "pages": 12})
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Returns `True` if the event was recorded on the server, `False` on any failure (network error, or
|
|
124
|
+
the server rejecting the event).
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## Floating licenses
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
# Check out a seat at session start
|
|
132
|
+
session = client.checkout(license_key)
|
|
133
|
+
if not session.success:
|
|
134
|
+
raise RuntimeError(f"No seats available: {session.message}")
|
|
135
|
+
|
|
136
|
+
token = session.session_token
|
|
137
|
+
|
|
138
|
+
# Heartbeat every 4-5 minutes to keep the seat alive
|
|
139
|
+
client.heartbeat(token)
|
|
140
|
+
|
|
141
|
+
# Release the seat when done
|
|
142
|
+
client.checkin(token)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## Offline license tokens
|
|
148
|
+
|
|
149
|
+
An offline activation token (`pc_offline_v1.<payload>.<signature>`) lets your app verify a
|
|
150
|
+
license with **zero network calls**, using ECDSA P-256 signature verification against your
|
|
151
|
+
tenant's public key (`GET /api/v1/{tenantSlug}/public-key`). Useful for air-gapped or
|
|
152
|
+
intermittently-connected deployments. Requires the optional `cryptography` package
|
|
153
|
+
(`pip install permitcore[offline]`) — a plain `pip install permitcore` install raises
|
|
154
|
+
`OfflineVerificationUnavailable` if you call these without it.
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
# Pure local verification — no network call. Never throws (except OfflineVerificationUnavailable
|
|
158
|
+
# if 'cryptography' isn't installed).
|
|
159
|
+
result = PermitCoreClient.verify_offline_token(token, public_key_base64)
|
|
160
|
+
|
|
161
|
+
if result.is_valid:
|
|
162
|
+
print(f"Valid! Product: {result.payload.product_name}")
|
|
163
|
+
else:
|
|
164
|
+
print(f"Invalid: {result.message}")
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
# Verify + bind to this device + persist locally (call once, e.g. at install time)
|
|
169
|
+
result = PermitCoreClient.activate_offline(token, public_key_base64, device_id)
|
|
170
|
+
|
|
171
|
+
# On every later launch — no token needed, reads the local cache, still no network call
|
|
172
|
+
result = PermitCoreClient.validate_offline(device_id)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
# Optional: ask the server to verify the token AND check its revocation status (requires network)
|
|
177
|
+
result = client.verify_offline_online(token)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
All four methods return an `OfflineTokenResult(is_valid, message, payload)`. `payload` (an
|
|
181
|
+
`OfflineTokenPayload`) carries `token_id`, `tenant_slug`, `kid`, `tenant_id`, `license_id`,
|
|
182
|
+
`license_key_hash`, `device_id`, `device_name`, `product_name`, `max_activations`, `issued_at`,
|
|
183
|
+
`expires_at`. `kid` identifies which of the tenant's signing keys produced the token (`None` on
|
|
184
|
+
tokens issued before key versioning existed) — informational only, `verify_offline_token()` still
|
|
185
|
+
verifies against whatever `public_key_base64` you pass it. `verify_offline_token()` and
|
|
186
|
+
`validate_offline()` never throw for malformed,
|
|
187
|
+
tampered, expired, or missing input — they just return `is_valid=False` with a descriptive
|
|
188
|
+
`message`.
|
|
189
|
+
|
|
190
|
+
`activate_offline()`'s local cache is stored under the user's home directory as
|
|
191
|
+
`.permitcore_offline_<hash>` (same convention as the `validate()`/`activate()` cache, keyed by
|
|
192
|
+
device ID instead of license key).
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
## Version enforcement
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
result = client.validate(license_key)
|
|
200
|
+
|
|
201
|
+
my_version = "2.3.0"
|
|
202
|
+
if result.min_version and my_version < result.min_version:
|
|
203
|
+
raise RuntimeError(f"Please update to version {result.min_version} or newer.")
|
|
204
|
+
if result.max_version and my_version > result.max_version:
|
|
205
|
+
raise RuntimeError(f"This build ({my_version}) is not licensed for versions above {result.max_version}.")
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Pass `version=my_version` to `validate()`/`activate()` to also have the *server* enforce this —
|
|
209
|
+
otherwise only client-side comparison happens.
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
## Offline grace pattern
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
result = client.validate(license_key) # falls back to cache automatically
|
|
217
|
+
|
|
218
|
+
if not result.is_valid:
|
|
219
|
+
raise RuntimeError(f"License invalid: {result.message}")
|
|
220
|
+
|
|
221
|
+
if result.is_offline:
|
|
222
|
+
# Server unreachable — running on cached result
|
|
223
|
+
show_notice("Running in offline mode. Connect to the internet to refresh your license.")
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
The cache is stored under the user's home directory as `.permitcore_cache_<hash>`. It expires
|
|
227
|
+
after `offline_grace_days` days.
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## Constructor options
|
|
232
|
+
|
|
233
|
+
```python
|
|
234
|
+
client = PermitCoreClient(
|
|
235
|
+
base_url="https://your-instance.com",
|
|
236
|
+
enable_offline_cache=True, # default — set False to always require network
|
|
237
|
+
timeout=5, # HTTP timeout in seconds
|
|
238
|
+
)
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
## LicenseResult reference
|
|
244
|
+
|
|
245
|
+
| Field | Type | Description |
|
|
246
|
+
|---|---|---|
|
|
247
|
+
| `is_valid` | `bool` | True if the license is active and valid |
|
|
248
|
+
| `product_name` | `Optional[str]` | Product the license belongs to |
|
|
249
|
+
| `remaining_activations` | `Optional[int]` | Slots left before MaxActivations is reached |
|
|
250
|
+
| `expires_at` | `Optional[str]` | Expiry date (ISO 8601 UTC), None if perpetual |
|
|
251
|
+
| `features` | `Optional[List[str]]` | Feature flag list, e.g. `["export", "api"]` |
|
|
252
|
+
| `custom_fields` | `Optional[Dict[str, str]]` | Arbitrary key/value metadata set on the license |
|
|
253
|
+
| `is_trial` | `bool` | True for trial licenses |
|
|
254
|
+
| `trial_days_remaining` | `Optional[int]` | Days until trial expires |
|
|
255
|
+
| `node_locked` | `bool` | True if bound to a specific device |
|
|
256
|
+
| `offline_grace_days` | `Optional[int]` | How many days the cache is valid |
|
|
257
|
+
| `min_version` / `max_version` | `Optional[str]` | Version enforcement bounds |
|
|
258
|
+
| `vendor_warning` | `Optional[str]` | Non-fatal message from the vendor |
|
|
259
|
+
| `message` | `Optional[str]` | Reason when `is_valid = False` |
|
|
260
|
+
| `is_offline` | `bool` | True when result came from local cache |
|
|
261
|
+
|
|
262
|
+
---
|
|
263
|
+
|
|
264
|
+
## Development
|
|
265
|
+
|
|
266
|
+
```bash
|
|
267
|
+
pip install -e ".[test]" # installs pytest + cryptography for the test run
|
|
268
|
+
pytest
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
`tests/test_vectors.py` runs this SDK's `verify_offline_token`/`verify_grace_cache_token` against
|
|
272
|
+
the shared, language-agnostic cross-SDK protocol vectors in `../../test-vectors/vectors.json`
|
|
273
|
+
(fixed ECDSA P-256/SHA-256 tokens every PermitCore SDK verifies identically — see that file's own
|
|
274
|
+
`schemaNote`) and checks the `validate`/`activate` request bodies this SDK builds match the shared
|
|
275
|
+
`requestShapes` key sets exactly.
|
|
276
|
+
|
|
277
|
+
`has_feature(feature: str) -> bool` — case-insensitive feature check.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
permitcore/__init__.py,sha256=RoEWvPPGZLSPSwMKkcILuoUELVVqjKGb_Y6useV_gh4,29312
|
|
2
|
+
permitcore-1.0.0.dist-info/METADATA,sha256=wVUYDckPJqPBBFq6lKVqmZwiJHcDHlWwXGS5YTDi-JQ,9562
|
|
3
|
+
permitcore-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
4
|
+
permitcore-1.0.0.dist-info/top_level.txt,sha256=q3ZnWbUDCzeFHdOD_b9MvYs-se4LT7Aj6muWWnR2Gro,11
|
|
5
|
+
permitcore-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
permitcore
|