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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ permitcore