authforge-sdk 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AuthForge
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,163 @@
1
+ Metadata-Version: 2.4
2
+ Name: authforge-sdk
3
+ Version: 1.0.0
4
+ Summary: Official Python SDK for AuthForge — credit-based license key authentication with Ed25519-verified responses.
5
+ Author: AuthForge
6
+ License: MIT
7
+ Project-URL: Homepage, https://authforge.cc
8
+ Project-URL: Documentation, https://docs.authforge.cc
9
+ Project-URL: Source, https://github.com/AuthForgeCC/authforge-python
10
+ Project-URL: Issues, https://github.com/AuthForgeCC/authforge-python/issues
11
+ Keywords: authforge,license,licensing,hwid,authentication
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Requires-Python: >=3.9
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: cryptography>=41.0.0
26
+ Dynamic: license-file
27
+
28
+ # AuthForge Python SDK
29
+
30
+ Official Python SDK for [AuthForge](https://authforge.cc) — credit-based license key authentication with Ed25519-verified responses.
31
+
32
+ Uses `cryptography` for Ed25519 verification. Works on Python 3.9+.
33
+
34
+ ## Installation
35
+
36
+ The distribution on [PyPI](https://pypi.org/project/authforge-sdk/) is **`authforge-sdk`** (same idea as scoped npm names: install name ≠ import path). After installing, import the **`authforge`** module:
37
+
38
+ ```bash
39
+ pip install authforge-sdk
40
+ ```
41
+
42
+ **Alternative:** copy `authforge.py` into your project if you need a single-file vendored layout (you must still satisfy the `cryptography` dependency yourself).
43
+
44
+ ## Quick Start
45
+
46
+ After **`pip install authforge-sdk`** (or vendoring `authforge.py`), use:
47
+
48
+ ```python
49
+ from authforge import AuthForgeClient
50
+
51
+ client = AuthForgeClient(
52
+ app_id="YOUR_APP_ID", # from your AuthForge dashboard
53
+ app_secret="YOUR_APP_SECRET", # from your AuthForge dashboard
54
+ public_key="YOUR_PUBLIC_KEY", # from your AuthForge dashboard
55
+ heartbeat_mode="SERVER", # "SERVER" or "LOCAL"
56
+ )
57
+
58
+ license_key = input("Enter license key: ")
59
+
60
+ if client.login(license_key):
61
+ print("Authenticated!")
62
+ # Your app logic here — heartbeats run automatically in the background
63
+ else:
64
+ print("Invalid license key.")
65
+ exit(1)
66
+ ```
67
+
68
+ ## Configuration
69
+
70
+ | Parameter | Type | Default | Description |
71
+ |---|---|---|---|
72
+ | `app_id` | str | required | Your application ID from the AuthForge dashboard |
73
+ | `app_secret` | str | required | Your application secret from the AuthForge dashboard |
74
+ | `public_key` | str | required | App Ed25519 public key (base64) from dashboard |
75
+ | `heartbeat_mode` | str | required | `"SERVER"` or `"LOCAL"` (see below) |
76
+ | `heartbeat_interval` | int | `900` | Seconds between heartbeat checks (any value ≥ 1; default 15 min) |
77
+ | `api_base_url` | str | `https://auth.authforge.cc` | API endpoint |
78
+ | `on_failure` | callable | `None` | Callback `(reason: str, exc: Exception | None)` on auth failure |
79
+ | `request_timeout` | int | `15` | HTTP request timeout in seconds |
80
+ | `ttl_seconds` | `int \| None` | `None` (server default: 86400) | Requested session token lifetime. Server clamps to `[3600, 604800]`; preserved across heartbeat refreshes. |
81
+
82
+ ## Billing
83
+
84
+ - **1 `login()` call = 1 credit** (one `/auth/validate` debit).
85
+ - **10 heartbeats on the same license = 1 credit** (billed every 10th successful heartbeat).
86
+
87
+ 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).
88
+
89
+ ## Methods
90
+
91
+ | Method | Returns | Description |
92
+ |---|---|---|
93
+ | `login(license_key)` | `bool` | Validates key and stores signed session (`sessionToken`, `expiresIn`, `appVariables`, `licenseVariables`) |
94
+ | `logout()` | `None` | Stops heartbeat and clears all session/auth state |
95
+ | `is_authenticated()` | `bool` | True when an active authenticated session exists |
96
+ | `get_session_data()` | `dict \| None` | Full decoded payload map |
97
+ | `get_app_variables()` | `dict \| None` | App-scoped variables map |
98
+ | `get_license_variables()` | `dict \| None` | License-scoped variables map |
99
+
100
+ ## Heartbeat Modes
101
+
102
+ **SERVER** — The SDK calls `/auth/heartbeat` every `heartbeat_interval` seconds with a fresh nonce, verifies signature + nonce, and triggers failure on invalid session state.
103
+
104
+ **LOCAL** — No network calls. The SDK re-verifies stored signature state and checks expiry timestamp locally. If expired, it triggers failure with `session_expired`.
105
+
106
+ ## Failure Handling
107
+
108
+ 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.
109
+
110
+ Recognized server errors:
111
+ `invalid_app`, `invalid_key`, `expired`, `revoked`, `hwid_mismatch`, `no_credits`, `blocked`, `rate_limited`, `replay_detected`, `app_disabled`, `session_expired`, `bad_request`
112
+
113
+ Request retries are automatic inside the internal HTTP layer:
114
+ - `rate_limited`: retry after 2s, then 5s (max 3 attempts total)
115
+ - network failure: retry once after 2s
116
+ - every retry regenerates a fresh nonce
117
+
118
+ ```python
119
+ def handle_auth_failure(reason, exception):
120
+ print(f"Auth failed: {reason}")
121
+ if exception:
122
+ print(f"Details: {exception}")
123
+ # Clean up and exit gracefully
124
+ sys.exit(1)
125
+
126
+ client = AuthForgeClient(
127
+ app_id="YOUR_APP_ID",
128
+ app_secret="YOUR_APP_SECRET",
129
+ public_key="YOUR_PUBLIC_KEY",
130
+ heartbeat_mode="SERVER",
131
+ on_failure=handle_auth_failure,
132
+ )
133
+ ```
134
+
135
+ ## How It Works
136
+
137
+ 1. **Login** — Collects a hardware fingerprint (MAC, CPU, disk serial), generates a random nonce, and sends everything to the AuthForge API. The server validates the license key, binds the HWID, deducts a credit, and returns a signed payload. The SDK verifies the Ed25519 signature and nonce to prevent replay attacks.
138
+
139
+ 2. **Heartbeat** — A background daemon thread checks in at the configured interval. In SERVER mode, it sends a fresh nonce and verifies the response. In LOCAL mode, it re-verifies the stored signature and checks expiry without network calls.
140
+
141
+ 3. **Crypto** — Both `/validate` and `/heartbeat` responses are signed by AuthForge with your app's Ed25519 private key. The SDK verifies every signed `payload` using your configured `public_key` and rejects tampered responses.
142
+
143
+ ## Hardware ID
144
+
145
+ The SDK generates a deterministic hardware fingerprint by hashing:
146
+ - MAC address
147
+ - CPU identifier
148
+ - Disk serial number
149
+
150
+ Each component falls back gracefully if it can't be read (e.g. permissions issues). The HWID is sent with every auth request so the server can enforce per-device license limits.
151
+
152
+ ## Test Vectors
153
+
154
+ The shared `test_vectors.json` file validates cross-language Ed25519 verification behavior.
155
+
156
+ ## Requirements
157
+
158
+ - Python 3.9+
159
+ - Dependency: `cryptography`
160
+
161
+ ## License
162
+
163
+ MIT
@@ -0,0 +1,136 @@
1
+ # AuthForge Python SDK
2
+
3
+ Official Python SDK for [AuthForge](https://authforge.cc) — credit-based license key authentication with Ed25519-verified responses.
4
+
5
+ Uses `cryptography` for Ed25519 verification. Works on Python 3.9+.
6
+
7
+ ## Installation
8
+
9
+ The distribution on [PyPI](https://pypi.org/project/authforge-sdk/) is **`authforge-sdk`** (same idea as scoped npm names: install name ≠ import path). After installing, import the **`authforge`** module:
10
+
11
+ ```bash
12
+ pip install authforge-sdk
13
+ ```
14
+
15
+ **Alternative:** copy `authforge.py` into your project if you need a single-file vendored layout (you must still satisfy the `cryptography` dependency yourself).
16
+
17
+ ## Quick Start
18
+
19
+ After **`pip install authforge-sdk`** (or vendoring `authforge.py`), use:
20
+
21
+ ```python
22
+ from authforge import AuthForgeClient
23
+
24
+ client = AuthForgeClient(
25
+ app_id="YOUR_APP_ID", # from your AuthForge dashboard
26
+ app_secret="YOUR_APP_SECRET", # from your AuthForge dashboard
27
+ public_key="YOUR_PUBLIC_KEY", # from your AuthForge dashboard
28
+ heartbeat_mode="SERVER", # "SERVER" or "LOCAL"
29
+ )
30
+
31
+ license_key = input("Enter license key: ")
32
+
33
+ if client.login(license_key):
34
+ print("Authenticated!")
35
+ # Your app logic here — heartbeats run automatically in the background
36
+ else:
37
+ print("Invalid license key.")
38
+ exit(1)
39
+ ```
40
+
41
+ ## Configuration
42
+
43
+ | Parameter | Type | Default | Description |
44
+ |---|---|---|---|
45
+ | `app_id` | str | required | Your application ID from the AuthForge dashboard |
46
+ | `app_secret` | str | required | Your application secret from the AuthForge dashboard |
47
+ | `public_key` | str | required | App Ed25519 public key (base64) from dashboard |
48
+ | `heartbeat_mode` | str | required | `"SERVER"` or `"LOCAL"` (see below) |
49
+ | `heartbeat_interval` | int | `900` | Seconds between heartbeat checks (any value ≥ 1; default 15 min) |
50
+ | `api_base_url` | str | `https://auth.authforge.cc` | API endpoint |
51
+ | `on_failure` | callable | `None` | Callback `(reason: str, exc: Exception | None)` on auth failure |
52
+ | `request_timeout` | int | `15` | HTTP request timeout in seconds |
53
+ | `ttl_seconds` | `int \| None` | `None` (server default: 86400) | Requested session token lifetime. Server clamps to `[3600, 604800]`; preserved across heartbeat refreshes. |
54
+
55
+ ## Billing
56
+
57
+ - **1 `login()` call = 1 credit** (one `/auth/validate` debit).
58
+ - **10 heartbeats on the same license = 1 credit** (billed every 10th successful heartbeat).
59
+
60
+ 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).
61
+
62
+ ## Methods
63
+
64
+ | Method | Returns | Description |
65
+ |---|---|---|
66
+ | `login(license_key)` | `bool` | Validates key and stores signed session (`sessionToken`, `expiresIn`, `appVariables`, `licenseVariables`) |
67
+ | `logout()` | `None` | Stops heartbeat and clears all session/auth state |
68
+ | `is_authenticated()` | `bool` | True when an active authenticated session exists |
69
+ | `get_session_data()` | `dict \| None` | Full decoded payload map |
70
+ | `get_app_variables()` | `dict \| None` | App-scoped variables map |
71
+ | `get_license_variables()` | `dict \| None` | License-scoped variables map |
72
+
73
+ ## Heartbeat Modes
74
+
75
+ **SERVER** — The SDK calls `/auth/heartbeat` every `heartbeat_interval` seconds with a fresh nonce, verifies signature + nonce, and triggers failure on invalid session state.
76
+
77
+ **LOCAL** — No network calls. The SDK re-verifies stored signature state and checks expiry timestamp locally. If expired, it triggers failure with `session_expired`.
78
+
79
+ ## Failure Handling
80
+
81
+ 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.
82
+
83
+ Recognized server errors:
84
+ `invalid_app`, `invalid_key`, `expired`, `revoked`, `hwid_mismatch`, `no_credits`, `blocked`, `rate_limited`, `replay_detected`, `app_disabled`, `session_expired`, `bad_request`
85
+
86
+ Request retries are automatic inside the internal HTTP layer:
87
+ - `rate_limited`: retry after 2s, then 5s (max 3 attempts total)
88
+ - network failure: retry once after 2s
89
+ - every retry regenerates a fresh nonce
90
+
91
+ ```python
92
+ def handle_auth_failure(reason, exception):
93
+ print(f"Auth failed: {reason}")
94
+ if exception:
95
+ print(f"Details: {exception}")
96
+ # Clean up and exit gracefully
97
+ sys.exit(1)
98
+
99
+ client = AuthForgeClient(
100
+ app_id="YOUR_APP_ID",
101
+ app_secret="YOUR_APP_SECRET",
102
+ public_key="YOUR_PUBLIC_KEY",
103
+ heartbeat_mode="SERVER",
104
+ on_failure=handle_auth_failure,
105
+ )
106
+ ```
107
+
108
+ ## How It Works
109
+
110
+ 1. **Login** — Collects a hardware fingerprint (MAC, CPU, disk serial), generates a random nonce, and sends everything to the AuthForge API. The server validates the license key, binds the HWID, deducts a credit, and returns a signed payload. The SDK verifies the Ed25519 signature and nonce to prevent replay attacks.
111
+
112
+ 2. **Heartbeat** — A background daemon thread checks in at the configured interval. In SERVER mode, it sends a fresh nonce and verifies the response. In LOCAL mode, it re-verifies the stored signature and checks expiry without network calls.
113
+
114
+ 3. **Crypto** — Both `/validate` and `/heartbeat` responses are signed by AuthForge with your app's Ed25519 private key. The SDK verifies every signed `payload` using your configured `public_key` and rejects tampered responses.
115
+
116
+ ## Hardware ID
117
+
118
+ The SDK generates a deterministic hardware fingerprint by hashing:
119
+ - MAC address
120
+ - CPU identifier
121
+ - Disk serial number
122
+
123
+ Each component falls back gracefully if it can't be read (e.g. permissions issues). The HWID is sent with every auth request so the server can enforce per-device license limits.
124
+
125
+ ## Test Vectors
126
+
127
+ The shared `test_vectors.json` file validates cross-language Ed25519 verification behavior.
128
+
129
+ ## Requirements
130
+
131
+ - Python 3.9+
132
+ - Dependency: `cryptography`
133
+
134
+ ## License
135
+
136
+ MIT
@@ -0,0 +1,500 @@
1
+ import base64
2
+ import hashlib
3
+ import json
4
+ import os
5
+ import platform
6
+ import secrets
7
+ import subprocess
8
+ import socket
9
+ import threading
10
+ import time
11
+ import urllib.error
12
+ import urllib.request
13
+ import uuid
14
+ from typing import Any, Callable, Dict, Optional
15
+ from cryptography.exceptions import InvalidSignature
16
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
17
+
18
+
19
+ DEFAULT_API_BASE_URL = "https://auth.authforge.cc"
20
+ RATE_LIMIT_RETRY_DELAYS = (2, 5)
21
+ NETWORK_RETRY_DELAY = 2
22
+ KNOWN_SERVER_ERRORS = {
23
+ "invalid_app",
24
+ "invalid_key",
25
+ "expired",
26
+ "revoked",
27
+ "hwid_mismatch",
28
+ "no_credits",
29
+ "app_burn_cap_reached",
30
+ "blocked",
31
+ "rate_limited",
32
+ "replay_detected",
33
+ "app_disabled",
34
+ "session_expired",
35
+ "bad_request",
36
+ "system_error",
37
+ }
38
+
39
+
40
+ class AuthForgeClient:
41
+ def __init__(
42
+ self,
43
+ app_id: str,
44
+ app_secret: str,
45
+ public_key: str,
46
+ heartbeat_mode: str,
47
+ heartbeat_interval: int = 900,
48
+ api_base_url: str = DEFAULT_API_BASE_URL,
49
+ on_failure: Optional[Callable[[str, Optional[Exception]], None]] = None,
50
+ request_timeout: int = 15,
51
+ ttl_seconds: Optional[int] = None,
52
+ ) -> None:
53
+ if not app_id or not isinstance(app_id, str):
54
+ raise ValueError("app_id must be a non-empty string")
55
+ if not app_secret or not isinstance(app_secret, str):
56
+ raise ValueError("app_secret must be a non-empty string")
57
+ if not public_key or not isinstance(public_key, str):
58
+ raise ValueError("public_key must be a non-empty base64 string")
59
+ mode = (heartbeat_mode or "").upper()
60
+ if mode not in {"LOCAL", "SERVER"}:
61
+ raise ValueError("heartbeat_mode must be LOCAL or SERVER")
62
+ if heartbeat_interval <= 0:
63
+ raise ValueError("heartbeat_interval must be > 0")
64
+
65
+ self.app_id = app_id
66
+ self.app_secret = app_secret
67
+ self.public_key = public_key
68
+ self.heartbeat_mode = mode
69
+ self.heartbeat_interval = int(heartbeat_interval)
70
+ self.api_base_url = api_base_url.rstrip("/")
71
+ self.on_failure = on_failure
72
+ self.request_timeout = request_timeout
73
+ # None / 0 / negative means "let the server pick its default (24h)".
74
+ # Server clamps to [3600, 604800]; we don't duplicate the clamp here.
75
+ self.ttl_seconds: Optional[int] = (
76
+ int(ttl_seconds) if isinstance(ttl_seconds, int) and ttl_seconds > 0 else None
77
+ )
78
+
79
+ self._lock = threading.Lock()
80
+ self._heartbeat_thread: Optional[threading.Thread] = None
81
+ self._heartbeat_started = False
82
+ self._heartbeat_stop = threading.Event()
83
+
84
+ self._license_key: Optional[str] = None
85
+ self._session_token: Optional[str] = None
86
+ self._session_expires_in: Optional[int] = None
87
+ self._last_nonce: Optional[str] = None
88
+ self._raw_payload_b64: Optional[str] = None
89
+ self._signature: Optional[str] = None
90
+ self._key_id: Optional[str] = None
91
+ self._session_data: Optional[Dict[str, Any]] = None
92
+ self._app_variables: Optional[Dict[str, Any]] = None
93
+ self._license_variables: Optional[Dict[str, Any]] = None
94
+ self._authenticated = False
95
+ self._hwid = self._get_hwid()
96
+ self._ed25519_public_key = self._load_public_key(public_key)
97
+
98
+ def login(self, license_key: str) -> bool:
99
+ if not license_key or not isinstance(license_key, str):
100
+ raise ValueError("license_key must be a non-empty string")
101
+
102
+ try:
103
+ self._validate_and_store(license_key)
104
+ self._start_heartbeat_once()
105
+ return True
106
+ except Exception as exc:
107
+ self._fail("login_failed", exc)
108
+ return False
109
+
110
+ def _start_heartbeat_once(self) -> None:
111
+ with self._lock:
112
+ if self._heartbeat_started:
113
+ return
114
+ self._heartbeat_stop.clear()
115
+ self._heartbeat_started = True
116
+ self._heartbeat_thread = threading.Thread(
117
+ target=self._heartbeat_loop,
118
+ name="AuthForgeHeartbeat",
119
+ daemon=True,
120
+ )
121
+ self._heartbeat_thread.start()
122
+
123
+ def _heartbeat_loop(self) -> None:
124
+ while not self._heartbeat_stop.wait(self.heartbeat_interval):
125
+ try:
126
+ if self.heartbeat_mode == "SERVER":
127
+ self._server_heartbeat()
128
+ else:
129
+ self._local_heartbeat()
130
+ except Exception as exc:
131
+ self._fail("heartbeat_failed", exc)
132
+ break
133
+
134
+ def _server_heartbeat(self) -> None:
135
+ with self._lock:
136
+ session_token = self._session_token
137
+ hwid = self._hwid
138
+ if not session_token:
139
+ raise RuntimeError("missing_session_token")
140
+
141
+ body = {
142
+ "appId": self.app_id,
143
+ "sessionToken": session_token,
144
+ "nonce": self._generate_nonce(),
145
+ "hwid": hwid,
146
+ }
147
+ response_obj = self._post_json("/auth/heartbeat", body)
148
+ expected_nonce = str(body.get("nonce", "")).strip()
149
+ self._apply_signed_response(
150
+ response_obj,
151
+ expected_nonce=expected_nonce,
152
+ license_key=None,
153
+ context="heartbeat",
154
+ )
155
+
156
+ def _local_heartbeat(self) -> None:
157
+ with self._lock:
158
+ raw_payload_b64 = self._raw_payload_b64
159
+ signature = self._signature
160
+ expires_in = self._session_expires_in
161
+ if not raw_payload_b64 or not signature:
162
+ raise RuntimeError("missing_local_verification_state")
163
+
164
+ self._verify_signature(raw_payload_b64, signature)
165
+
166
+ if expires_in is None:
167
+ raise RuntimeError("missing_session_expiry")
168
+
169
+ now = int(time.time())
170
+ if now >= int(expires_in):
171
+ raise RuntimeError("session_expired")
172
+
173
+ def _validate_and_store(self, license_key: str) -> None:
174
+ body: Dict[str, Any] = {
175
+ "appId": self.app_id,
176
+ "appSecret": self.app_secret,
177
+ "licenseKey": license_key,
178
+ "hwid": self._hwid,
179
+ "nonce": self._generate_nonce(),
180
+ }
181
+ if self.ttl_seconds is not None:
182
+ body["ttlSeconds"] = self.ttl_seconds
183
+ response_obj = self._post_json("/auth/validate", body)
184
+ expected_nonce = str(body.get("nonce", "")).strip()
185
+ self._apply_signed_response(
186
+ response_obj,
187
+ expected_nonce=expected_nonce,
188
+ license_key=license_key,
189
+ context="validate",
190
+ )
191
+
192
+ def _apply_signed_response(
193
+ self,
194
+ response_obj: Dict[str, Any],
195
+ expected_nonce: str,
196
+ license_key: Optional[str],
197
+ context: str,
198
+ ) -> None:
199
+ status = response_obj.get("status")
200
+ if not self._is_success_status(status):
201
+ error_code = self._extract_server_error(response_obj)
202
+ raise ValueError(error_code)
203
+
204
+ raw_payload_b64 = self._require_str(response_obj, "payload")
205
+ signature = self._require_str(response_obj, "signature")
206
+ payload_json = self._decode_payload_json(raw_payload_b64)
207
+
208
+ received_nonce = str(payload_json.get("nonce", "")).strip()
209
+ if received_nonce != expected_nonce:
210
+ raise ValueError("nonce_mismatch")
211
+
212
+ self._verify_signature(raw_payload_b64, signature)
213
+
214
+ session_token = str(payload_json.get("sessionToken", "")).strip()
215
+ if not session_token:
216
+ raise ValueError("missing_sessionToken")
217
+ key_id = response_obj.get("keyId")
218
+ if key_id is not None and not isinstance(key_id, str):
219
+ raise ValueError("invalid_keyId")
220
+
221
+ expires_from_token = self._extract_expires_in_from_session_token(session_token)
222
+ expires_from_payload = payload_json.get("expiresIn")
223
+
224
+ expires_in = expires_from_token
225
+ if expires_in is None and expires_from_payload is not None:
226
+ expires_in = int(expires_from_payload)
227
+ if expires_in is None:
228
+ raise ValueError("missing_expiresIn")
229
+
230
+ with self._lock:
231
+ if license_key is not None:
232
+ self._license_key = license_key
233
+ self._session_token = session_token
234
+ self._session_expires_in = int(expires_in)
235
+ self._last_nonce = expected_nonce
236
+ self._raw_payload_b64 = raw_payload_b64
237
+ self._signature = signature
238
+ self._key_id = key_id
239
+ self._session_data = dict(payload_json)
240
+ self._app_variables = self._extract_optional_map(payload_json.get("appVariables"))
241
+ self._license_variables = self._extract_optional_map(payload_json.get("licenseVariables"))
242
+ self._authenticated = True
243
+
244
+ def _post_json(self, path: str, data: Dict[str, Any]) -> Dict[str, Any]:
245
+ url = f"{self.api_base_url}{path}"
246
+ body = dict(data)
247
+ rate_attempt = 0
248
+ while True:
249
+ if rate_attempt > 0 and "nonce" in body:
250
+ body["nonce"] = self._generate_nonce()
251
+
252
+ network_attempt = 0
253
+ while True:
254
+ payload_bytes = json.dumps(body, separators=(",", ":")).encode("utf-8")
255
+ request = urllib.request.Request(
256
+ url=url,
257
+ data=payload_bytes,
258
+ headers={"Content-Type": "application/json"},
259
+ method="POST",
260
+ )
261
+ try:
262
+ with urllib.request.urlopen(request, timeout=self.request_timeout) as response:
263
+ raw_response = response.read().decode("utf-8")
264
+ status_code = int(getattr(response, "status", 200))
265
+ obj = self._parse_response_object(raw_response)
266
+ data.clear()
267
+ data.update(body)
268
+ break
269
+ except urllib.error.HTTPError as exc:
270
+ status_code = int(exc.code)
271
+ try:
272
+ detail = exc.read().decode("utf-8")
273
+ obj = self._parse_response_object(detail)
274
+ except Exception:
275
+ raise RuntimeError(f"http_error_{status_code}") from exc
276
+ data.clear()
277
+ data.update(body)
278
+ break
279
+ except (urllib.error.URLError, socket.timeout, TimeoutError) as exc:
280
+ if network_attempt == 0:
281
+ network_attempt += 1
282
+ time.sleep(NETWORK_RETRY_DELAY)
283
+ continue
284
+ self._fail("network_error", exc)
285
+ raise RuntimeError(f"url_error: {exc}") from exc
286
+
287
+ is_rate_limited = (
288
+ status_code == 429
289
+ or self._extract_server_error(obj) == "rate_limited"
290
+ )
291
+ if is_rate_limited and rate_attempt < len(RATE_LIMIT_RETRY_DELAYS):
292
+ time.sleep(RATE_LIMIT_RETRY_DELAYS[rate_attempt])
293
+ rate_attempt += 1
294
+ continue
295
+ return obj
296
+
297
+ def _parse_response_object(self, raw_response: str) -> Dict[str, Any]:
298
+ try:
299
+ obj = json.loads(raw_response)
300
+ except json.JSONDecodeError as exc:
301
+ raise ValueError("invalid_json_response") from exc
302
+ if not isinstance(obj, dict):
303
+ raise ValueError("response_not_json_object")
304
+ return obj
305
+
306
+ def _get_hwid(self) -> str:
307
+ mac = self._safe_mac_address()
308
+ cpu = self._safe_cpu_info()
309
+ disk = self._safe_disk_serial()
310
+ material = f"mac:{mac}|cpu:{cpu}|disk:{disk}"
311
+ return hashlib.sha256(material.encode("utf-8")).hexdigest()
312
+
313
+ def _safe_mac_address(self) -> str:
314
+ try:
315
+ return f"{uuid.getnode():012x}"
316
+ except Exception:
317
+ return "mac-unavailable"
318
+
319
+ def _safe_cpu_info(self) -> str:
320
+ try:
321
+ value = platform.processor() or platform.machine() or "cpu-unavailable"
322
+ return str(value)
323
+ except Exception:
324
+ return "cpu-unavailable"
325
+
326
+ def _safe_disk_serial(self) -> str:
327
+ system = platform.system().lower()
328
+ try:
329
+ if "windows" in system:
330
+ return self._run_command(["wmic", "diskdrive", "get", "serialnumber"])
331
+ if "linux" in system:
332
+ out = self._run_command(["lsblk", "-ndo", "SERIAL"])
333
+ if out and out.strip():
334
+ return out
335
+ return self._run_command(["udevadm", "info", "--query=property", "--name=sda"])
336
+ if "darwin" in system:
337
+ return self._run_command(["system_profiler", "SPStorageDataType"])
338
+ except Exception:
339
+ pass
340
+ return "disk-unavailable"
341
+
342
+ def _run_command(self, command: list[str]) -> str:
343
+ try:
344
+ output = subprocess.check_output(
345
+ command,
346
+ stderr=subprocess.DEVNULL,
347
+ timeout=2,
348
+ )
349
+ cleaned = " ".join(output.decode("utf-8", errors="ignore").split())
350
+ return cleaned[:256] if cleaned else "empty"
351
+ except Exception:
352
+ return "unavailable"
353
+
354
+ def _decode_payload_json(self, payload_b64: str) -> Dict[str, Any]:
355
+ payload_bytes = self._decode_base64_any(payload_b64)
356
+ try:
357
+ payload_obj = json.loads(payload_bytes.decode("utf-8"))
358
+ except Exception as exc:
359
+ raise ValueError("invalid_payload_json") from exc
360
+ if not isinstance(payload_obj, dict):
361
+ raise ValueError("payload_not_json_object")
362
+ return payload_obj
363
+
364
+ def _decode_base64_any(self, value: str) -> bytes:
365
+ padded = self._add_base64_padding(value)
366
+ try:
367
+ return base64.b64decode(padded, validate=False)
368
+ except Exception:
369
+ return base64.urlsafe_b64decode(padded)
370
+
371
+ def _extract_expires_in_from_session_token(self, session_token: str) -> Optional[int]:
372
+ payload = self._decode_session_token_body(session_token)
373
+ if payload is None:
374
+ return None
375
+ value = payload.get("exp")
376
+ if value is None:
377
+ return None
378
+ return int(value)
379
+
380
+ def _decode_session_token_body(self, session_token: str) -> Optional[Dict[str, Any]]:
381
+ parts = session_token.split(".")
382
+ if len(parts) < 2:
383
+ return None
384
+ padded = self._add_base64_padding(parts[0])
385
+ try:
386
+ decoded = base64.urlsafe_b64decode(padded)
387
+ payload = json.loads(decoded.decode("utf-8"))
388
+ except Exception:
389
+ return None
390
+ if not isinstance(payload, dict):
391
+ return None
392
+ return payload
393
+
394
+ def _add_base64_padding(self, text: str) -> str:
395
+ remainder = len(text) % 4
396
+ if remainder == 0:
397
+ return text
398
+ return text + ("=" * (4 - remainder))
399
+
400
+ def _load_public_key(self, public_key_b64: str) -> Ed25519PublicKey:
401
+ try:
402
+ public_key_bytes = base64.b64decode(
403
+ self._add_base64_padding(public_key_b64), validate=True
404
+ )
405
+ except Exception as exc:
406
+ raise ValueError("invalid_public_key") from exc
407
+ if len(public_key_bytes) != 32:
408
+ raise ValueError("invalid_public_key_length")
409
+ return Ed25519PublicKey.from_public_bytes(public_key_bytes)
410
+
411
+ def _verify_signature(self, raw_payload_b64: str, signature: str) -> None:
412
+ try:
413
+ signature_bytes = base64.b64decode(
414
+ self._add_base64_padding(signature), validate=True
415
+ )
416
+ except Exception as exc:
417
+ raise ValueError("invalid_signature_encoding") from exc
418
+ try:
419
+ self._ed25519_public_key.verify(
420
+ signature_bytes,
421
+ raw_payload_b64.encode("utf-8"),
422
+ )
423
+ except InvalidSignature as exc:
424
+ raise ValueError("signature_mismatch") from exc
425
+
426
+ def _generate_nonce(self) -> str:
427
+ return secrets.token_hex(16)
428
+
429
+ def _is_success_status(self, status: Any) -> bool:
430
+ if isinstance(status, bool):
431
+ return status
432
+ if status is None:
433
+ return False
434
+ value = str(status).strip().lower()
435
+ return value in {"ok", "success", "valid", "true", "1"}
436
+
437
+ def _require_str(self, obj: Dict[str, Any], key: str) -> str:
438
+ value = obj.get(key)
439
+ if value is None:
440
+ raise ValueError(f"missing_{key}")
441
+ text = str(value)
442
+ if not text:
443
+ raise ValueError(f"empty_{key}")
444
+ return text
445
+
446
+ def _extract_server_error(self, obj: Dict[str, Any]) -> str:
447
+ raw_error = str(obj.get("error", "")).strip().lower()
448
+ if raw_error in KNOWN_SERVER_ERRORS:
449
+ return raw_error
450
+ status = str(obj.get("status", "")).strip().lower()
451
+ if status in KNOWN_SERVER_ERRORS:
452
+ return status
453
+ return "unknown_error"
454
+
455
+ def _extract_optional_map(self, value: Any) -> Optional[Dict[str, Any]]:
456
+ if isinstance(value, dict):
457
+ return dict(value)
458
+ return None
459
+
460
+ def _fail(self, reason: str, exc: Optional[Exception] = None) -> None:
461
+ if self.on_failure is not None:
462
+ try:
463
+ self.on_failure(reason, exc)
464
+ return
465
+ except Exception:
466
+ pass
467
+ os._exit(1)
468
+
469
+ def logout(self) -> None:
470
+ self._heartbeat_stop.set()
471
+ with self._lock:
472
+ self._license_key = None
473
+ self._session_token = None
474
+ self._session_expires_in = None
475
+ self._last_nonce = None
476
+ self._raw_payload_b64 = None
477
+ self._signature = None
478
+ self._key_id = None
479
+ self._session_data = None
480
+ self._app_variables = None
481
+ self._license_variables = None
482
+ self._authenticated = False
483
+ self._heartbeat_started = False
484
+ self._heartbeat_thread = None
485
+
486
+ def is_authenticated(self) -> bool:
487
+ with self._lock:
488
+ return self._authenticated and bool(self._session_token)
489
+
490
+ def get_session_data(self) -> Optional[Dict[str, Any]]:
491
+ with self._lock:
492
+ return dict(self._session_data) if self._session_data is not None else None
493
+
494
+ def get_app_variables(self) -> Optional[Dict[str, Any]]:
495
+ with self._lock:
496
+ return dict(self._app_variables) if self._app_variables is not None else None
497
+
498
+ def get_license_variables(self) -> Optional[Dict[str, Any]]:
499
+ with self._lock:
500
+ return dict(self._license_variables) if self._license_variables is not None else None
@@ -0,0 +1,163 @@
1
+ Metadata-Version: 2.4
2
+ Name: authforge-sdk
3
+ Version: 1.0.0
4
+ Summary: Official Python SDK for AuthForge — credit-based license key authentication with Ed25519-verified responses.
5
+ Author: AuthForge
6
+ License: MIT
7
+ Project-URL: Homepage, https://authforge.cc
8
+ Project-URL: Documentation, https://docs.authforge.cc
9
+ Project-URL: Source, https://github.com/AuthForgeCC/authforge-python
10
+ Project-URL: Issues, https://github.com/AuthForgeCC/authforge-python/issues
11
+ Keywords: authforge,license,licensing,hwid,authentication
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Requires-Python: >=3.9
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: cryptography>=41.0.0
26
+ Dynamic: license-file
27
+
28
+ # AuthForge Python SDK
29
+
30
+ Official Python SDK for [AuthForge](https://authforge.cc) — credit-based license key authentication with Ed25519-verified responses.
31
+
32
+ Uses `cryptography` for Ed25519 verification. Works on Python 3.9+.
33
+
34
+ ## Installation
35
+
36
+ The distribution on [PyPI](https://pypi.org/project/authforge-sdk/) is **`authforge-sdk`** (same idea as scoped npm names: install name ≠ import path). After installing, import the **`authforge`** module:
37
+
38
+ ```bash
39
+ pip install authforge-sdk
40
+ ```
41
+
42
+ **Alternative:** copy `authforge.py` into your project if you need a single-file vendored layout (you must still satisfy the `cryptography` dependency yourself).
43
+
44
+ ## Quick Start
45
+
46
+ After **`pip install authforge-sdk`** (or vendoring `authforge.py`), use:
47
+
48
+ ```python
49
+ from authforge import AuthForgeClient
50
+
51
+ client = AuthForgeClient(
52
+ app_id="YOUR_APP_ID", # from your AuthForge dashboard
53
+ app_secret="YOUR_APP_SECRET", # from your AuthForge dashboard
54
+ public_key="YOUR_PUBLIC_KEY", # from your AuthForge dashboard
55
+ heartbeat_mode="SERVER", # "SERVER" or "LOCAL"
56
+ )
57
+
58
+ license_key = input("Enter license key: ")
59
+
60
+ if client.login(license_key):
61
+ print("Authenticated!")
62
+ # Your app logic here — heartbeats run automatically in the background
63
+ else:
64
+ print("Invalid license key.")
65
+ exit(1)
66
+ ```
67
+
68
+ ## Configuration
69
+
70
+ | Parameter | Type | Default | Description |
71
+ |---|---|---|---|
72
+ | `app_id` | str | required | Your application ID from the AuthForge dashboard |
73
+ | `app_secret` | str | required | Your application secret from the AuthForge dashboard |
74
+ | `public_key` | str | required | App Ed25519 public key (base64) from dashboard |
75
+ | `heartbeat_mode` | str | required | `"SERVER"` or `"LOCAL"` (see below) |
76
+ | `heartbeat_interval` | int | `900` | Seconds between heartbeat checks (any value ≥ 1; default 15 min) |
77
+ | `api_base_url` | str | `https://auth.authforge.cc` | API endpoint |
78
+ | `on_failure` | callable | `None` | Callback `(reason: str, exc: Exception | None)` on auth failure |
79
+ | `request_timeout` | int | `15` | HTTP request timeout in seconds |
80
+ | `ttl_seconds` | `int \| None` | `None` (server default: 86400) | Requested session token lifetime. Server clamps to `[3600, 604800]`; preserved across heartbeat refreshes. |
81
+
82
+ ## Billing
83
+
84
+ - **1 `login()` call = 1 credit** (one `/auth/validate` debit).
85
+ - **10 heartbeats on the same license = 1 credit** (billed every 10th successful heartbeat).
86
+
87
+ 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).
88
+
89
+ ## Methods
90
+
91
+ | Method | Returns | Description |
92
+ |---|---|---|
93
+ | `login(license_key)` | `bool` | Validates key and stores signed session (`sessionToken`, `expiresIn`, `appVariables`, `licenseVariables`) |
94
+ | `logout()` | `None` | Stops heartbeat and clears all session/auth state |
95
+ | `is_authenticated()` | `bool` | True when an active authenticated session exists |
96
+ | `get_session_data()` | `dict \| None` | Full decoded payload map |
97
+ | `get_app_variables()` | `dict \| None` | App-scoped variables map |
98
+ | `get_license_variables()` | `dict \| None` | License-scoped variables map |
99
+
100
+ ## Heartbeat Modes
101
+
102
+ **SERVER** — The SDK calls `/auth/heartbeat` every `heartbeat_interval` seconds with a fresh nonce, verifies signature + nonce, and triggers failure on invalid session state.
103
+
104
+ **LOCAL** — No network calls. The SDK re-verifies stored signature state and checks expiry timestamp locally. If expired, it triggers failure with `session_expired`.
105
+
106
+ ## Failure Handling
107
+
108
+ 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.
109
+
110
+ Recognized server errors:
111
+ `invalid_app`, `invalid_key`, `expired`, `revoked`, `hwid_mismatch`, `no_credits`, `blocked`, `rate_limited`, `replay_detected`, `app_disabled`, `session_expired`, `bad_request`
112
+
113
+ Request retries are automatic inside the internal HTTP layer:
114
+ - `rate_limited`: retry after 2s, then 5s (max 3 attempts total)
115
+ - network failure: retry once after 2s
116
+ - every retry regenerates a fresh nonce
117
+
118
+ ```python
119
+ def handle_auth_failure(reason, exception):
120
+ print(f"Auth failed: {reason}")
121
+ if exception:
122
+ print(f"Details: {exception}")
123
+ # Clean up and exit gracefully
124
+ sys.exit(1)
125
+
126
+ client = AuthForgeClient(
127
+ app_id="YOUR_APP_ID",
128
+ app_secret="YOUR_APP_SECRET",
129
+ public_key="YOUR_PUBLIC_KEY",
130
+ heartbeat_mode="SERVER",
131
+ on_failure=handle_auth_failure,
132
+ )
133
+ ```
134
+
135
+ ## How It Works
136
+
137
+ 1. **Login** — Collects a hardware fingerprint (MAC, CPU, disk serial), generates a random nonce, and sends everything to the AuthForge API. The server validates the license key, binds the HWID, deducts a credit, and returns a signed payload. The SDK verifies the Ed25519 signature and nonce to prevent replay attacks.
138
+
139
+ 2. **Heartbeat** — A background daemon thread checks in at the configured interval. In SERVER mode, it sends a fresh nonce and verifies the response. In LOCAL mode, it re-verifies the stored signature and checks expiry without network calls.
140
+
141
+ 3. **Crypto** — Both `/validate` and `/heartbeat` responses are signed by AuthForge with your app's Ed25519 private key. The SDK verifies every signed `payload` using your configured `public_key` and rejects tampered responses.
142
+
143
+ ## Hardware ID
144
+
145
+ The SDK generates a deterministic hardware fingerprint by hashing:
146
+ - MAC address
147
+ - CPU identifier
148
+ - Disk serial number
149
+
150
+ Each component falls back gracefully if it can't be read (e.g. permissions issues). The HWID is sent with every auth request so the server can enforce per-device license limits.
151
+
152
+ ## Test Vectors
153
+
154
+ The shared `test_vectors.json` file validates cross-language Ed25519 verification behavior.
155
+
156
+ ## Requirements
157
+
158
+ - Python 3.9+
159
+ - Dependency: `cryptography`
160
+
161
+ ## License
162
+
163
+ MIT
@@ -0,0 +1,9 @@
1
+ LICENSE
2
+ README.md
3
+ authforge.py
4
+ pyproject.toml
5
+ authforge_sdk.egg-info/PKG-INFO
6
+ authforge_sdk.egg-info/SOURCES.txt
7
+ authforge_sdk.egg-info/dependency_links.txt
8
+ authforge_sdk.egg-info/requires.txt
9
+ authforge_sdk.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ cryptography>=41.0.0
@@ -0,0 +1 @@
1
+ authforge
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "authforge-sdk"
7
+ version = "1.0.0"
8
+ description = "Official Python SDK for AuthForge — credit-based license key authentication with Ed25519-verified responses."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "AuthForge" }]
13
+ keywords = ["authforge", "license", "licensing", "hwid", "authentication"]
14
+ classifiers = [
15
+ "Development Status :: 5 - Production/Stable",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.9",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ ]
26
+ dependencies = ["cryptography>=41.0.0"]
27
+
28
+ [project.urls]
29
+ Homepage = "https://authforge.cc"
30
+ Documentation = "https://docs.authforge.cc"
31
+ Source = "https://github.com/AuthForgeCC/authforge-python"
32
+ Issues = "https://github.com/AuthForgeCC/authforge-python/issues"
33
+
34
+ [tool.setuptools]
35
+ py-modules = ["authforge"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+