authforge-sdk 1.0.2__tar.gz → 1.0.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {authforge_sdk-1.0.2/authforge_sdk.egg-info → authforge_sdk-1.0.4}/PKG-INFO +5 -2
- {authforge_sdk-1.0.2 → authforge_sdk-1.0.4}/README.md +4 -1
- {authforge_sdk-1.0.2 → authforge_sdk-1.0.4}/authforge.py +142 -30
- {authforge_sdk-1.0.2 → authforge_sdk-1.0.4/authforge_sdk.egg-info}/PKG-INFO +5 -2
- {authforge_sdk-1.0.2 → authforge_sdk-1.0.4}/pyproject.toml +1 -1
- {authforge_sdk-1.0.2 → authforge_sdk-1.0.4}/LICENSE +0 -0
- {authforge_sdk-1.0.2 → authforge_sdk-1.0.4}/authforge_sdk.egg-info/SOURCES.txt +0 -0
- {authforge_sdk-1.0.2 → authforge_sdk-1.0.4}/authforge_sdk.egg-info/dependency_links.txt +0 -0
- {authforge_sdk-1.0.2 → authforge_sdk-1.0.4}/authforge_sdk.egg-info/requires.txt +0 -0
- {authforge_sdk-1.0.2 → authforge_sdk-1.0.4}/authforge_sdk.egg-info/top_level.txt +0 -0
- {authforge_sdk-1.0.2 → authforge_sdk-1.0.4}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: authforge-sdk
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.4
|
|
4
4
|
Summary: Official Python SDK for AuthForge — credit-based license key authentication with Ed25519-verified responses.
|
|
5
5
|
Author: AuthForge
|
|
6
6
|
License: MIT
|
|
@@ -94,7 +94,7 @@ client = AuthForgeClient(
|
|
|
94
94
|
|
|
95
95
|
## Billing
|
|
96
96
|
|
|
97
|
-
- **1 `login()` call = 1 credit** (one `/auth/validate` debit).
|
|
97
|
+
- **1 `login()` or `validate_license()` call = 1 credit** (one `/auth/validate` debit each).
|
|
98
98
|
- **10 heartbeats on the same license = 1 credit** (billed every 10th successful heartbeat).
|
|
99
99
|
|
|
100
100
|
A desktop app running 6h/day at a 15-minute interval burns ~3–4 credits/day. A server app running 24/7 at a 1-minute interval burns ~145 credits/day — pick the interval based on how fast you need revocations to propagate (they always land on the **next** heartbeat).
|
|
@@ -104,6 +104,7 @@ A desktop app running 6h/day at a 15-minute interval burns ~3–4 credits/day. A
|
|
|
104
104
|
| Method | Returns | Description |
|
|
105
105
|
|---|---|---|
|
|
106
106
|
| `login(license_key)` | `bool` | Validates key and stores signed session (`sessionToken`, `expiresIn`, `appVariables`, `licenseVariables`) |
|
|
107
|
+
| `validate_license(license_key)` | `ValidateLicenseResult` | Same `/auth/validate` + signatures as `login`; does not store session or start heartbeats; returns a dict with `valid` / `code` and **never** calls `on_failure` or `os._exit` |
|
|
107
108
|
| `self_ban(...)` | `dict` | Requests `/auth/selfban` to blacklist HWID/IP and optionally revoke (session-authenticated only) |
|
|
108
109
|
| `logout()` | `None` | Stops heartbeat and clears all session/auth state |
|
|
109
110
|
| `is_authenticated()` | `bool` | True when an active authenticated session exists |
|
|
@@ -121,6 +122,8 @@ A desktop app running 6h/day at a 15-minute interval burns ~3–4 credits/day. A
|
|
|
121
122
|
|
|
122
123
|
If authentication fails (login rejected, heartbeat fails, signature mismatch, etc.), the SDK calls your `on_failure` callback if one is provided. If no callback is set, **the SDK calls `os._exit(1)` to terminate the process.** This is intentional — it prevents your app from running without a valid license.
|
|
123
124
|
|
|
125
|
+
**`validate_license()`** does not trigger `on_failure` or `os._exit` — check `result["valid"]` and `result["code"]`.
|
|
126
|
+
|
|
124
127
|
Recognized server errors:
|
|
125
128
|
`invalid_app`, `invalid_key`, `expired`, `revoked`, `hwid_mismatch`, `no_credits`, `blocked`, `rate_limited`, `replay_detected`, `app_disabled`, `session_expired`, `revoke_requires_session`, `bad_request`
|
|
126
129
|
|
|
@@ -67,7 +67,7 @@ client = AuthForgeClient(
|
|
|
67
67
|
|
|
68
68
|
## Billing
|
|
69
69
|
|
|
70
|
-
- **1 `login()` call = 1 credit** (one `/auth/validate` debit).
|
|
70
|
+
- **1 `login()` or `validate_license()` call = 1 credit** (one `/auth/validate` debit each).
|
|
71
71
|
- **10 heartbeats on the same license = 1 credit** (billed every 10th successful heartbeat).
|
|
72
72
|
|
|
73
73
|
A desktop app running 6h/day at a 15-minute interval burns ~3–4 credits/day. A server app running 24/7 at a 1-minute interval burns ~145 credits/day — pick the interval based on how fast you need revocations to propagate (they always land on the **next** heartbeat).
|
|
@@ -77,6 +77,7 @@ A desktop app running 6h/day at a 15-minute interval burns ~3–4 credits/day. A
|
|
|
77
77
|
| Method | Returns | Description |
|
|
78
78
|
|---|---|---|
|
|
79
79
|
| `login(license_key)` | `bool` | Validates key and stores signed session (`sessionToken`, `expiresIn`, `appVariables`, `licenseVariables`) |
|
|
80
|
+
| `validate_license(license_key)` | `ValidateLicenseResult` | Same `/auth/validate` + signatures as `login`; does not store session or start heartbeats; returns a dict with `valid` / `code` and **never** calls `on_failure` or `os._exit` |
|
|
80
81
|
| `self_ban(...)` | `dict` | Requests `/auth/selfban` to blacklist HWID/IP and optionally revoke (session-authenticated only) |
|
|
81
82
|
| `logout()` | `None` | Stops heartbeat and clears all session/auth state |
|
|
82
83
|
| `is_authenticated()` | `bool` | True when an active authenticated session exists |
|
|
@@ -94,6 +95,8 @@ A desktop app running 6h/day at a 15-minute interval burns ~3–4 credits/day. A
|
|
|
94
95
|
|
|
95
96
|
If authentication fails (login rejected, heartbeat fails, signature mismatch, etc.), the SDK calls your `on_failure` callback if one is provided. If no callback is set, **the SDK calls `os._exit(1)` to terminate the process.** This is intentional — it prevents your app from running without a valid license.
|
|
96
97
|
|
|
98
|
+
**`validate_license()`** does not trigger `on_failure` or `os._exit` — check `result["valid"]` and `result["code"]`.
|
|
99
|
+
|
|
97
100
|
Recognized server errors:
|
|
98
101
|
`invalid_app`, `invalid_key`, `expired`, `revoked`, `hwid_mismatch`, `no_credits`, `blocked`, `rate_limited`, `replay_detected`, `app_disabled`, `session_expired`, `revoke_requires_session`, `bad_request`
|
|
99
102
|
|
|
@@ -11,14 +11,36 @@ import time
|
|
|
11
11
|
import urllib.error
|
|
12
12
|
import urllib.request
|
|
13
13
|
import uuid
|
|
14
|
-
from typing import Any, Callable, Dict, Optional
|
|
14
|
+
from typing import Any, Callable, Dict, Iterable, List, Literal, Optional, Sequence, TypedDict, Union
|
|
15
15
|
from cryptography.exceptions import InvalidSignature
|
|
16
16
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
17
17
|
|
|
18
18
|
|
|
19
|
+
PublicKeyArg = Union[str, Sequence[str]]
|
|
20
|
+
|
|
21
|
+
|
|
19
22
|
DEFAULT_API_BASE_URL = "https://auth.authforge.cc"
|
|
20
23
|
RATE_LIMIT_RETRY_DELAYS = (2, 5)
|
|
21
24
|
NETWORK_RETRY_DELAY = 2
|
|
25
|
+
class ValidateLicenseSuccess(TypedDict):
|
|
26
|
+
valid: Literal[True]
|
|
27
|
+
session_token: str
|
|
28
|
+
expires_in: int
|
|
29
|
+
session_data: Dict[str, Any]
|
|
30
|
+
app_variables: Optional[Dict[str, Any]]
|
|
31
|
+
license_variables: Optional[Dict[str, Any]]
|
|
32
|
+
key_id: Optional[str]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ValidateLicenseFailure(TypedDict):
|
|
36
|
+
valid: Literal[False]
|
|
37
|
+
code: str
|
|
38
|
+
error: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
ValidateLicenseResult = Union[ValidateLicenseSuccess, ValidateLicenseFailure]
|
|
42
|
+
|
|
43
|
+
|
|
22
44
|
KNOWN_SERVER_ERRORS = {
|
|
23
45
|
"invalid_app",
|
|
24
46
|
"invalid_key",
|
|
@@ -43,7 +65,7 @@ class AuthForgeClient:
|
|
|
43
65
|
self,
|
|
44
66
|
app_id: str,
|
|
45
67
|
app_secret: str,
|
|
46
|
-
public_key:
|
|
68
|
+
public_key: PublicKeyArg,
|
|
47
69
|
heartbeat_mode: str,
|
|
48
70
|
heartbeat_interval: int = 900,
|
|
49
71
|
api_base_url: str = DEFAULT_API_BASE_URL,
|
|
@@ -56,8 +78,11 @@ class AuthForgeClient:
|
|
|
56
78
|
raise ValueError("app_id must be a non-empty string")
|
|
57
79
|
if not app_secret or not isinstance(app_secret, str):
|
|
58
80
|
raise ValueError("app_secret must be a non-empty string")
|
|
59
|
-
|
|
60
|
-
|
|
81
|
+
public_key_list = self._normalize_public_key_list(public_key)
|
|
82
|
+
if not public_key_list:
|
|
83
|
+
raise ValueError(
|
|
84
|
+
"public_key must be a non-empty base64 string or list of base64 strings"
|
|
85
|
+
)
|
|
61
86
|
mode = (heartbeat_mode or "").upper()
|
|
62
87
|
if mode not in {"LOCAL", "SERVER"}:
|
|
63
88
|
raise ValueError("heartbeat_mode must be LOCAL or SERVER")
|
|
@@ -66,7 +91,11 @@ class AuthForgeClient:
|
|
|
66
91
|
|
|
67
92
|
self.app_id = app_id
|
|
68
93
|
self.app_secret = app_secret
|
|
69
|
-
|
|
94
|
+
# `public_key` is the historical public attribute. We now hold the
|
|
95
|
+
# full trust list to support key rotation, but expose the first entry
|
|
96
|
+
# as `public_key` for callers that read it directly.
|
|
97
|
+
self.public_keys: List[str] = public_key_list
|
|
98
|
+
self.public_key = public_key_list[0]
|
|
70
99
|
self.heartbeat_mode = mode
|
|
71
100
|
self.heartbeat_interval = int(heartbeat_interval)
|
|
72
101
|
self.api_base_url = api_base_url.rstrip("/")
|
|
@@ -95,7 +124,9 @@ class AuthForgeClient:
|
|
|
95
124
|
self._license_variables: Optional[Dict[str, Any]] = None
|
|
96
125
|
self._authenticated = False
|
|
97
126
|
self._hwid = self._resolve_hwid(hwid_override)
|
|
98
|
-
self.
|
|
127
|
+
self._ed25519_public_keys: List[Ed25519PublicKey] = [
|
|
128
|
+
self._load_public_key(k) for k in public_key_list
|
|
129
|
+
]
|
|
99
130
|
|
|
100
131
|
def login(self, license_key: str) -> bool:
|
|
101
132
|
if not license_key or not isinstance(license_key, str):
|
|
@@ -109,6 +140,36 @@ class AuthForgeClient:
|
|
|
109
140
|
self._fail("login_failed", exc)
|
|
110
141
|
return False
|
|
111
142
|
|
|
143
|
+
def validate_license(self, license_key: str) -> ValidateLicenseResult:
|
|
144
|
+
"""Validate like :meth:`login` (same /auth/validate + signatures) without storing
|
|
145
|
+
session state or starting the heartbeat thread."""
|
|
146
|
+
if not license_key or not isinstance(license_key, str):
|
|
147
|
+
raise ValueError("license_key must be a non-empty string")
|
|
148
|
+
try:
|
|
149
|
+
body: Dict[str, Any] = {
|
|
150
|
+
"appId": self.app_id,
|
|
151
|
+
"appSecret": self.app_secret,
|
|
152
|
+
"licenseKey": license_key,
|
|
153
|
+
"hwid": self._hwid,
|
|
154
|
+
"nonce": self._generate_nonce(),
|
|
155
|
+
}
|
|
156
|
+
if self.ttl_seconds is not None:
|
|
157
|
+
body["ttlSeconds"] = self.ttl_seconds
|
|
158
|
+
response_obj = self._post_json("/auth/validate", body, skip_failure_hook=True)
|
|
159
|
+
expected_nonce = str(body.get("nonce", "")).strip()
|
|
160
|
+
parsed = self._parse_validate_success(response_obj, expected_nonce)
|
|
161
|
+
return {
|
|
162
|
+
"valid": True,
|
|
163
|
+
"session_token": parsed["session_token"],
|
|
164
|
+
"expires_in": parsed["expires_in"],
|
|
165
|
+
"session_data": parsed["session_data"],
|
|
166
|
+
"app_variables": parsed["app_variables"],
|
|
167
|
+
"license_variables": parsed["license_variables"],
|
|
168
|
+
"key_id": parsed["key_id"],
|
|
169
|
+
}
|
|
170
|
+
except Exception as exc:
|
|
171
|
+
return {"valid": False, "code": str(exc), "error": str(exc)}
|
|
172
|
+
|
|
112
173
|
def self_ban(
|
|
113
174
|
self,
|
|
114
175
|
*,
|
|
@@ -250,13 +311,9 @@ class AuthForgeClient:
|
|
|
250
311
|
context="validate",
|
|
251
312
|
)
|
|
252
313
|
|
|
253
|
-
def
|
|
254
|
-
self,
|
|
255
|
-
|
|
256
|
-
expected_nonce: str,
|
|
257
|
-
license_key: Optional[str],
|
|
258
|
-
context: str,
|
|
259
|
-
) -> None:
|
|
314
|
+
def _parse_validate_success(
|
|
315
|
+
self, response_obj: Dict[str, Any], expected_nonce: str
|
|
316
|
+
) -> Dict[str, Any]:
|
|
260
317
|
status = response_obj.get("status")
|
|
261
318
|
if not self._is_success_status(status):
|
|
262
319
|
error_code = self._extract_server_error(response_obj)
|
|
@@ -288,21 +345,46 @@ class AuthForgeClient:
|
|
|
288
345
|
if expires_in is None:
|
|
289
346
|
raise ValueError("missing_expiresIn")
|
|
290
347
|
|
|
348
|
+
return {
|
|
349
|
+
"session_token": session_token,
|
|
350
|
+
"expires_in": int(expires_in),
|
|
351
|
+
"session_data": dict(payload_json),
|
|
352
|
+
"app_variables": self._extract_optional_map(payload_json.get("appVariables")),
|
|
353
|
+
"license_variables": self._extract_optional_map(
|
|
354
|
+
payload_json.get("licenseVariables")
|
|
355
|
+
),
|
|
356
|
+
"key_id": key_id if isinstance(key_id, str) else None,
|
|
357
|
+
"raw_payload_b64": raw_payload_b64,
|
|
358
|
+
"signature": signature,
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
def _apply_signed_response(
|
|
362
|
+
self,
|
|
363
|
+
response_obj: Dict[str, Any],
|
|
364
|
+
expected_nonce: str,
|
|
365
|
+
license_key: Optional[str],
|
|
366
|
+
context: str,
|
|
367
|
+
) -> None:
|
|
368
|
+
parsed = self._parse_validate_success(response_obj, expected_nonce)
|
|
369
|
+
_ = context
|
|
370
|
+
|
|
291
371
|
with self._lock:
|
|
292
372
|
if license_key is not None:
|
|
293
373
|
self._license_key = license_key
|
|
294
|
-
self._session_token = session_token
|
|
295
|
-
self._session_expires_in = int(expires_in)
|
|
374
|
+
self._session_token = parsed["session_token"]
|
|
375
|
+
self._session_expires_in = int(parsed["expires_in"])
|
|
296
376
|
self._last_nonce = expected_nonce
|
|
297
|
-
self._raw_payload_b64 = raw_payload_b64
|
|
298
|
-
self._signature = signature
|
|
299
|
-
self._key_id = key_id
|
|
300
|
-
self._session_data = dict(
|
|
301
|
-
self._app_variables =
|
|
302
|
-
self._license_variables =
|
|
377
|
+
self._raw_payload_b64 = parsed["raw_payload_b64"]
|
|
378
|
+
self._signature = parsed["signature"]
|
|
379
|
+
self._key_id = parsed["key_id"]
|
|
380
|
+
self._session_data = dict(parsed["session_data"])
|
|
381
|
+
self._app_variables = parsed["app_variables"]
|
|
382
|
+
self._license_variables = parsed["license_variables"]
|
|
303
383
|
self._authenticated = True
|
|
304
384
|
|
|
305
|
-
def _post_json(
|
|
385
|
+
def _post_json(
|
|
386
|
+
self, path: str, data: Dict[str, Any], *, skip_failure_hook: bool = False
|
|
387
|
+
) -> Dict[str, Any]:
|
|
306
388
|
url = f"{self.api_base_url}{path}"
|
|
307
389
|
body = dict(data)
|
|
308
390
|
rate_attempt = 0
|
|
@@ -342,7 +424,8 @@ class AuthForgeClient:
|
|
|
342
424
|
network_attempt += 1
|
|
343
425
|
time.sleep(NETWORK_RETRY_DELAY)
|
|
344
426
|
continue
|
|
345
|
-
|
|
427
|
+
if not skip_failure_hook:
|
|
428
|
+
self._fail("network_error", exc)
|
|
346
429
|
raise RuntimeError(f"url_error: {exc}") from exc
|
|
347
430
|
|
|
348
431
|
is_rate_limited = (
|
|
@@ -483,13 +566,42 @@ class AuthForgeClient:
|
|
|
483
566
|
)
|
|
484
567
|
except Exception as exc:
|
|
485
568
|
raise ValueError("invalid_signature_encoding") from exc
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
569
|
+
# During a key rotation the SDK may be pinned to the previous key
|
|
570
|
+
# while a new server-side key signs responses (or vice-versa). Trust
|
|
571
|
+
# any key in the configured list.
|
|
572
|
+
payload_bytes = raw_payload_b64.encode("utf-8")
|
|
573
|
+
for key in self._ed25519_public_keys:
|
|
574
|
+
try:
|
|
575
|
+
key.verify(signature_bytes, payload_bytes)
|
|
576
|
+
return
|
|
577
|
+
except InvalidSignature:
|
|
578
|
+
continue
|
|
579
|
+
raise ValueError("signature_mismatch")
|
|
580
|
+
|
|
581
|
+
@staticmethod
|
|
582
|
+
def _normalize_public_key_list(value: PublicKeyArg) -> List[str]:
|
|
583
|
+
"""Coerce the public_key constructor arg to a list of base64 strings.
|
|
584
|
+
|
|
585
|
+
Accepts:
|
|
586
|
+
- "abc..." single-key historical contract
|
|
587
|
+
- ["abc...", "def..."] current first, previous after
|
|
588
|
+
- "abc...,def..." env-var convenience form
|
|
589
|
+
"""
|
|
590
|
+
keys: List[str] = []
|
|
591
|
+
candidates: Iterable[Any]
|
|
592
|
+
if isinstance(value, str):
|
|
593
|
+
candidates = value.split(",") if "," in value else [value]
|
|
594
|
+
elif isinstance(value, Sequence):
|
|
595
|
+
candidates = value
|
|
596
|
+
else:
|
|
597
|
+
return []
|
|
598
|
+
for entry in candidates:
|
|
599
|
+
if not isinstance(entry, str):
|
|
600
|
+
continue
|
|
601
|
+
trimmed = entry.strip()
|
|
602
|
+
if trimmed:
|
|
603
|
+
keys.append(trimmed)
|
|
604
|
+
return keys
|
|
493
605
|
|
|
494
606
|
def _generate_nonce(self) -> str:
|
|
495
607
|
return secrets.token_hex(16)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: authforge-sdk
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.4
|
|
4
4
|
Summary: Official Python SDK for AuthForge — credit-based license key authentication with Ed25519-verified responses.
|
|
5
5
|
Author: AuthForge
|
|
6
6
|
License: MIT
|
|
@@ -94,7 +94,7 @@ client = AuthForgeClient(
|
|
|
94
94
|
|
|
95
95
|
## Billing
|
|
96
96
|
|
|
97
|
-
- **1 `login()` call = 1 credit** (one `/auth/validate` debit).
|
|
97
|
+
- **1 `login()` or `validate_license()` call = 1 credit** (one `/auth/validate` debit each).
|
|
98
98
|
- **10 heartbeats on the same license = 1 credit** (billed every 10th successful heartbeat).
|
|
99
99
|
|
|
100
100
|
A desktop app running 6h/day at a 15-minute interval burns ~3–4 credits/day. A server app running 24/7 at a 1-minute interval burns ~145 credits/day — pick the interval based on how fast you need revocations to propagate (they always land on the **next** heartbeat).
|
|
@@ -104,6 +104,7 @@ A desktop app running 6h/day at a 15-minute interval burns ~3–4 credits/day. A
|
|
|
104
104
|
| Method | Returns | Description |
|
|
105
105
|
|---|---|---|
|
|
106
106
|
| `login(license_key)` | `bool` | Validates key and stores signed session (`sessionToken`, `expiresIn`, `appVariables`, `licenseVariables`) |
|
|
107
|
+
| `validate_license(license_key)` | `ValidateLicenseResult` | Same `/auth/validate` + signatures as `login`; does not store session or start heartbeats; returns a dict with `valid` / `code` and **never** calls `on_failure` or `os._exit` |
|
|
107
108
|
| `self_ban(...)` | `dict` | Requests `/auth/selfban` to blacklist HWID/IP and optionally revoke (session-authenticated only) |
|
|
108
109
|
| `logout()` | `None` | Stops heartbeat and clears all session/auth state |
|
|
109
110
|
| `is_authenticated()` | `bool` | True when an active authenticated session exists |
|
|
@@ -121,6 +122,8 @@ A desktop app running 6h/day at a 15-minute interval burns ~3–4 credits/day. A
|
|
|
121
122
|
|
|
122
123
|
If authentication fails (login rejected, heartbeat fails, signature mismatch, etc.), the SDK calls your `on_failure` callback if one is provided. If no callback is set, **the SDK calls `os._exit(1)` to terminate the process.** This is intentional — it prevents your app from running without a valid license.
|
|
123
124
|
|
|
125
|
+
**`validate_license()`** does not trigger `on_failure` or `os._exit` — check `result["valid"]` and `result["code"]`.
|
|
126
|
+
|
|
124
127
|
Recognized server errors:
|
|
125
128
|
`invalid_app`, `invalid_key`, `expired`, `revoked`, `hwid_mismatch`, `no_credits`, `blocked`, `rate_limited`, `replay_detected`, `app_disabled`, `session_expired`, `revoke_requires_session`, `bad_request`
|
|
126
129
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "authforge-sdk"
|
|
7
|
-
version = "1.0.
|
|
7
|
+
version = "1.0.4"
|
|
8
8
|
description = "Official Python SDK for AuthForge — credit-based license key authentication with Ed25519-verified responses."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|