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.
- authforge_sdk-1.0.0/LICENSE +21 -0
- authforge_sdk-1.0.0/PKG-INFO +163 -0
- authforge_sdk-1.0.0/README.md +136 -0
- authforge_sdk-1.0.0/authforge.py +500 -0
- authforge_sdk-1.0.0/authforge_sdk.egg-info/PKG-INFO +163 -0
- authforge_sdk-1.0.0/authforge_sdk.egg-info/SOURCES.txt +9 -0
- authforge_sdk-1.0.0/authforge_sdk.egg-info/dependency_links.txt +1 -0
- authforge_sdk-1.0.0/authforge_sdk.egg-info/requires.txt +1 -0
- authforge_sdk-1.0.0/authforge_sdk.egg-info/top_level.txt +1 -0
- authforge_sdk-1.0.0/pyproject.toml +35 -0
- authforge_sdk-1.0.0/setup.cfg +4 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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"]
|