authforge-sdk 1.4.1__tar.gz → 2.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.4.1/authforge_sdk.egg-info → authforge_sdk-2.0.0}/PKG-INFO +104 -16
- {authforge_sdk-1.4.1 → authforge_sdk-2.0.0}/README.md +103 -15
- {authforge_sdk-1.4.1 → authforge_sdk-2.0.0}/authforge.py +221 -9
- {authforge_sdk-1.4.1 → authforge_sdk-2.0.0/authforge_sdk.egg-info}/PKG-INFO +104 -16
- {authforge_sdk-1.4.1 → authforge_sdk-2.0.0}/pyproject.toml +1 -1
- {authforge_sdk-1.4.1 → authforge_sdk-2.0.0}/LICENSE +0 -0
- {authforge_sdk-1.4.1 → authforge_sdk-2.0.0}/authforge_sdk.egg-info/SOURCES.txt +0 -0
- {authforge_sdk-1.4.1 → authforge_sdk-2.0.0}/authforge_sdk.egg-info/dependency_links.txt +0 -0
- {authforge_sdk-1.4.1 → authforge_sdk-2.0.0}/authforge_sdk.egg-info/requires.txt +0 -0
- {authforge_sdk-1.4.1 → authforge_sdk-2.0.0}/authforge_sdk.egg-info/top_level.txt +0 -0
- {authforge_sdk-1.4.1 → authforge_sdk-2.0.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: authforge-sdk
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0.0
|
|
4
4
|
Summary: Official Python SDK for AuthForge — credit-based license key authentication with Ed25519-verified responses.
|
|
5
5
|
Author: AuthForge
|
|
6
6
|
License: MIT
|
|
@@ -38,7 +38,7 @@ Uses `cryptography` for Ed25519 verification. Works on Python 3.9+.
|
|
|
38
38
|
2. **Run through the grace period.** By default the app keeps running on the signed session with no further network calls. A background check re-verifies the signature locally and fails when the session TTL expires. The grace period equals the session TTL: default 24h, and the server clamps requested values to 1h to 7d.
|
|
39
39
|
3. **Optionally enable online check-ins.** With `online_heartbeat=True`, the SDK also calls `POST /auth/heartbeat` every `heartbeat_interval` seconds for fast revocation and concurrent-use detection.
|
|
40
40
|
|
|
41
|
-
Separately, for machines that can **never** reach the internet, an operator can mint a signed **offline license file (`.authforge`)** in the AuthForge dashboard or Developer API. The SDK verifies it locally with your app public key: see [Offline license files](#offline-license-files-authforge).
|
|
41
|
+
Separately, for machines that can **never** reach the internet, an operator can mint a signed **offline license file (`.authforge`)** in the AuthForge dashboard or Developer API. The SDK verifies it locally with your app public key: see [Offline license files](#offline-license-files-authforge). If the machine can go online **once**, the end user can get that file themselves with `activate_offline()`: see [Self-serve offline activation](#self-serve-offline-activation).
|
|
42
42
|
|
|
43
43
|
## Features
|
|
44
44
|
|
|
@@ -50,8 +50,9 @@ Everything in this list ships in `authforge.py` today:
|
|
|
50
50
|
- **Grace period by default**: after one successful activation, the app runs on the signed session (no network) until the TTL expires.
|
|
51
51
|
- **Online check-ins** (opt-in): periodic `/auth/heartbeat` calls for fast revocation and concurrent-use detection.
|
|
52
52
|
- **Offline license files (`.authforge`)**: `login_from_file()` / `verify_license_file()` verify a cloud-minted, Ed25519-signed file with zero network access for air-gapped machines.
|
|
53
|
+
- **Self-serve offline activation**: `activate_offline()` turns a license key into a lifetime, HWID-bound `.authforge` file with one online call, then runs offline from that file.
|
|
53
54
|
- **Nonce anti-replay**: a fresh 128-bit nonce is sent on every request and the echoed nonce in the signed payload is checked before the response is accepted.
|
|
54
|
-
- **HWID
|
|
55
|
+
- **HWID v2**: per-app device id derived from the OS machine id (macOS `IOPlatformUUID`, Linux `/etc/machine-id`, Windows `MachineGuid`), identical across all AuthForge SDKs for the same app and machine.
|
|
55
56
|
- **`hwid_override`**: bind to any identity instead of the machine (for example `tg:<id>`, `discord:<id>`).
|
|
56
57
|
- **Seat enforcement**: the server binds each HWID into a license's free slots up to `maxHwidSlots`; `hwid_count` / `max_hwid_slots` are surfaced on the result. A shared (unlimited-seat) key skips per-device binding.
|
|
57
58
|
- **Self-ban** (`self_ban()`) for anti-tamper response, both pre-session and post-session.
|
|
@@ -118,7 +119,7 @@ client = AuthForgeClient(
|
|
|
118
119
|
| `on_failure` | callable | `None` | Callback `(reason: str, exc: Exception | None)` on auth failure |
|
|
119
120
|
| `request_timeout` | int | `15` | HTTP request timeout in seconds |
|
|
120
121
|
| `ttl_seconds` | `int \| None` | `None` (server default: 86400) | The grace period duration: how long the app keeps running on the signed session without contacting AuthForge. Server clamps to `[3600, 604800]` (1h to 7d); preserved across check-in refreshes. |
|
|
121
|
-
| `hwid_override` | `str \| None` | `None` | Optional custom hardware/subject identifier. When set to a non-empty value, the SDK uses it instead of machine
|
|
122
|
+
| `hwid_override` | `str \| None` | `None` | Optional custom hardware/subject identifier. When set to a non-empty value, the SDK uses it instead of deriving the HWID from the OS machine id. |
|
|
122
123
|
| `online_heartbeat` | bool (keyword-only) | `False` | Enable online check-ins: periodic `POST /auth/heartbeat` for fast revocation and concurrent-use detection. |
|
|
123
124
|
|
|
124
125
|
### Identity-based binding example (Telegram/Discord)
|
|
@@ -189,12 +190,81 @@ if client.login_from_file("license.authforge"):
|
|
|
189
190
|
print(client.get_license_variables())
|
|
190
191
|
```
|
|
191
192
|
|
|
192
|
-
|
|
193
|
+
Since 2.0 every AuthForge SDK computes the same HWID for the same app id and machine, but HWIDs collected before upgrading to 2.0 no longer match: collect the HWID again after upgrading. After `login_from_file()`, `get_session_kind()` returns `"offline"` (`"online"` after `login()`, `None` when logged out).
|
|
193
194
|
|
|
194
195
|
`verify_license_file()` (module function and client method) performs the same checks without touching client state. Failure codes, in check order: `bad_armor`, `bad_signature`, `unsupported_version`, `malformed_payload`, `wrong_app`, `expired`, `hwid_mismatch`. `login_from_file()` reports them through `on_failure("offline_login_failed", exc)` and returns `False`; it never calls `os._exit`.
|
|
195
196
|
|
|
196
197
|
File format (version 1): PEM-style armor with informational headers, a base64 JSON payload (`v`, `appId`, `licenseKey`, `jti`, `kid`, `issuedAt`, `expiresAt`, `hwid` policy, optional label/variable snapshots) and a detached Ed25519 signature over the UTF-8 bytes of the base64 payload string - the same contract as `/auth/validate`. See `offline_license_vectors.json` for conformance vectors.
|
|
197
198
|
|
|
199
|
+
## Self-serve offline activation
|
|
200
|
+
|
|
201
|
+
A separate mode for apps that sell a one-time license and then run with no network: the end user types the license key once, `activate_offline()` makes **one** online call (`POST /auth/offline/activate`) and gets back a lifetime `.authforge` file bound to this machine's HWID. From then on the app starts with `login_from_file()` and never contacts AuthForge again.
|
|
202
|
+
|
|
203
|
+
How it differs from air-gapped offline files:
|
|
204
|
+
|
|
205
|
+
- It **needs the App Secret**, because it is an online call. Air-gapped builds that only use `login_from_file()` still ship with `app_secret=None`.
|
|
206
|
+
- The app owner opts the app in (app settings in the dashboard). Only **perpetual**, **seat-limited** licenses can self-serve; each license can produce files for at most `maxHwidSlots` distinct machines, ever. HWID resets do not free those machine slots.
|
|
207
|
+
- **Billing:** a new machine costs the app owner **2 credits**, once. The same machine activating again (reinstall, deleted file) gets the identical file back for **free**. A refused activation costs nothing.
|
|
208
|
+
- **A refund or chargeback revokes the license online but cannot reach files already issued.** A lifetime file keeps working on its machine.
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
import os
|
|
212
|
+
import sys
|
|
213
|
+
|
|
214
|
+
from authforge import AuthForgeClient, AuthForgeError
|
|
215
|
+
|
|
216
|
+
LICENSE_PATH = os.path.join(os.path.expanduser("~"), ".myapp", "license.authforge")
|
|
217
|
+
|
|
218
|
+
client = AuthForgeClient(
|
|
219
|
+
app_id="YOUR_APP_ID",
|
|
220
|
+
app_secret="YOUR_APP_SECRET", # required for activate_offline (online call)
|
|
221
|
+
public_key="YOUR_PUBLIC_KEY",
|
|
222
|
+
on_failure=lambda reason, exc: None, # login_from_file reports why it failed here
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
# 1. No network, no credits: run from the file if it is there and valid.
|
|
226
|
+
if not (os.path.exists(LICENSE_PATH) and client.login_from_file(LICENSE_PATH)):
|
|
227
|
+
# 2. No file yet, or it fails (e.g. hwid_mismatch after a hardware change).
|
|
228
|
+
key = input("License key: ").strip()
|
|
229
|
+
try:
|
|
230
|
+
client.activate_offline(key, path=LICENSE_PATH)
|
|
231
|
+
except AuthForgeError as exc:
|
|
232
|
+
if exc.code == "offline_activation_limit_reached":
|
|
233
|
+
print("This license is active on its maximum number of machines. Please contact the vendor.")
|
|
234
|
+
elif exc.code in {"invalid_key", "revoked", "expired"}:
|
|
235
|
+
print("That license key is not valid.")
|
|
236
|
+
elif exc.code == "hwid_mismatch":
|
|
237
|
+
print("No free seat on this license for this machine.")
|
|
238
|
+
elif exc.transient:
|
|
239
|
+
print(f"Could not activate right now ({exc.code}). Check your connection and try again.")
|
|
240
|
+
else:
|
|
241
|
+
print(f"Activation failed: {exc.code}")
|
|
242
|
+
sys.exit(1)
|
|
243
|
+
|
|
244
|
+
print("Licensed:", client.get_license_variables())
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
`activate_offline(license_key, path=None)` returns `True` on success and raises `AuthForgeError` otherwise. It:
|
|
248
|
+
|
|
249
|
+
1. Fails fast with the usual "app_secret is required" `ValueError` (no request) when the client has no App Secret.
|
|
250
|
+
2. Sends `appId`, `appSecret`, `licenseKey`, the same HWID `login()` sends and a fresh nonce, with the same retries as `login()` (`rate_limited` after 2s then 5s, one retry on a network error).
|
|
251
|
+
3. Verifies the returned file with your public key(s), app id and this machine's HWID **before** writing anything. A file that fails raises `offline_file_rejected`; the message includes the verifier's reason (`bad_signature`, `wrong_app`, `hwid_mismatch`, ...).
|
|
252
|
+
4. If `path` is set, writes the file byte-for-byte and atomically (temp file in the same directory, then rename), creating parent directories.
|
|
253
|
+
5. Leaves the client authenticated exactly as `login_from_file()` does: `get_session_kind()` is `"offline"`, variables and `get_offline_license()` are set, no background checks start. `get_offline_license()["replay"]` is `True` when the server returned a file this machine already had.
|
|
254
|
+
|
|
255
|
+
On any failure nothing is written to `path` (an existing file there is left as it was), the call does not authenticate the client (an existing session is kept), and neither `on_failure` nor `os._exit` is called.
|
|
256
|
+
|
|
257
|
+
| Code | Kind | Meaning |
|
|
258
|
+
| --- | --- | --- |
|
|
259
|
+
| `offline_activation_disabled` | fatal | The app has not opted in to self-serve activation |
|
|
260
|
+
| `offline_activation_requires_perpetual` | fatal | The license has an expiry; only perpetual licenses can self-serve |
|
|
261
|
+
| `offline_activation_requires_seats` | fatal | Unlimited-seat (shared) licenses cannot self-serve |
|
|
262
|
+
| `offline_activation_limit_reached` | fatal | Every machine slot on the license is used; the buyer must contact the vendor |
|
|
263
|
+
| `offline_file_rejected` | fatal | SDK code: the returned file failed local verification; nothing was written |
|
|
264
|
+
| `hwid_mismatch` | fatal | No free seat on the license for this machine (server code, not the verifier's) |
|
|
265
|
+
| `file_write_failed` | transient | The verified file could not be written to `path`; activating again is free |
|
|
266
|
+
| `invalid_key`, `revoked`, `expired`, `no_credits`, `rate_limited`, `unexpected_response`, `network_error`, ... | as everywhere else | Existing codes keep their meaning and classification |
|
|
267
|
+
|
|
198
268
|
## Migrating from heartbeat_mode
|
|
199
269
|
|
|
200
270
|
Earlier releases required `heartbeat_mode="LOCAL"` or `"SERVER"`. The argument is now optional and deprecated; it still works but emits a `DeprecationWarning`.
|
|
@@ -217,6 +287,7 @@ For code that still reads it, `client.heartbeat_mode` remains available and refl
|
|
|
217
287
|
|
|
218
288
|
- **1 `login()` or `validate_license()` call = 1 credit** (one `/auth/validate` debit each).
|
|
219
289
|
- **10 online check-ins on the same license = 1 credit** (billed every 10th successful `/auth/heartbeat`). Grace period checks are local and free.
|
|
290
|
+
- **1 new offline activation (`activate_offline()`) = 2 credits**, charged to the app owner; re-activating the same machine is free. `login_from_file()` is always free.
|
|
220
291
|
|
|
221
292
|
A desktop app running 6h/day with online check-ins at a 15-minute interval burns ~3-4 credits/day. The server enforces `/auth/heartbeat` at 6 requests/minute per license key, so keep intervals at 10 seconds or higher and pick the interval based on how fast you need revocations to propagate (they always land on the **next** check-in).
|
|
222
293
|
|
|
@@ -226,8 +297,9 @@ A desktop app running 6h/day with online check-ins at a 15-minute interval burns
|
|
|
226
297
|
|---|---|---|
|
|
227
298
|
| `login(license_key)` | `bool` | Activates: validates the key online and stores the signed session (`sessionToken`, `expiresIn`, `appVariables`, `licenseVariables`) |
|
|
228
299
|
| `validate_license(license_key)` | `ValidateLicenseResult` | Same `/auth/validate` + signatures as `login`; does not store session or start background checks; returns a dict with `valid` / `code` and **never** calls `on_failure` or `os._exit` |
|
|
229
|
-
| `self_ban(...)` | `dict` | Requests `/auth/selfban` to blacklist HWID/IP and optionally revoke (session-authenticated only) |
|
|
300
|
+
| `self_ban(...)` | `dict` | Requests `/auth/selfban` to blacklist HWID/IP and optionally revoke (session-authenticated only). Raises `ValueError` on any failure (a network failure as `AuthForgeError` `network_error` / `timeout`); never calls `on_failure` or `os._exit` |
|
|
230
301
|
| `login_from_file(path_or_text)` | `bool` | Authorizes from an offline `.authforge` file with no network; never starts background checks; failures go to `on_failure("offline_login_failed", …)` |
|
|
302
|
+
| `activate_offline(license_key, path=None)` | `bool` | Self-serve offline activation: one online call returns a lifetime HWID-bound `.authforge` file, verified locally, written atomically to `path` and applied like `login_from_file`. Needs the App Secret; raises `AuthForgeError` on refusal; never calls `on_failure` or `os._exit` |
|
|
231
303
|
| `verify_license_file(path_or_text, *, now=None)` | `VerifyLicenseFileResult` | Verifies a `.authforge` file with this client's app id / keys / HWID without changing state |
|
|
232
304
|
| `get_offline_license()` | `dict \| None` | Metadata of the offline file in use (`jti`, `expires_at`, `hwid_policy`, …) |
|
|
233
305
|
| `get_session_kind()` | `"online" \| "offline" \| None` | Which kind of session the client holds (`None` when logged out) |
|
|
@@ -247,7 +319,11 @@ If authentication fails (activation rejected, check-in fails, signature mismatch
|
|
|
247
319
|
**`validate_license()`** does not trigger `on_failure` or `os._exit`: check `result["valid"]` and `result["code"]`.
|
|
248
320
|
|
|
249
321
|
Recognized server errors (`KNOWN_SERVER_ERRORS`):
|
|
250
|
-
`invalid_app`, `invalid_key`, `expired`, `revoked`, `hwid_mismatch`, `no_credits`, `app_burn_cap_reached`, `blocked`, `rate_limited`, `replay_detected`, `app_disabled`, `session_expired`, `revoke_requires_session`, `bad_request`, `malformed_request`, `demo_quota_exceeded`, `system_error`. Codes added to the server later are passed through unchanged.
|
|
322
|
+
`invalid_app`, `invalid_key`, `expired`, `revoked`, `hwid_mismatch`, `no_credits`, `app_burn_cap_reached`, `blocked`, `rate_limited`, `replay_detected`, `app_disabled`, `session_expired`, `revoke_requires_session`, `bad_request`, `malformed_request`, `demo_quota_exceeded`, `system_error`, and from `activate_offline()`: `offline_activation_disabled`, `offline_activation_requires_perpetual`, `offline_activation_requires_seats`, `offline_activation_limit_reached`, plus the SDK code `offline_file_rejected`. Codes added to the server later are passed through unchanged.
|
|
323
|
+
|
|
324
|
+
**`activate_offline()`** does not trigger `on_failure` or `os._exit` either: it raises `AuthForgeError` (see [Self-serve offline activation](#self-serve-offline-activation)).
|
|
325
|
+
|
|
326
|
+
**`self_ban()`** does not trigger `on_failure` or `os._exit` either: every failure raises, a server rejection as `ValueError(code)` and a network failure as `AuthForgeError` with `exc.code` `network_error` or `timeout`.
|
|
251
327
|
|
|
252
328
|
Request retries are automatic inside the internal HTTP layer:
|
|
253
329
|
- `rate_limited`, or HTTP 429 with no error code: retry after 2s, then 5s (max 3 attempts total)
|
|
@@ -266,10 +342,10 @@ A failed check-in only counts as an AuthForge verdict when the body is a JSON ob
|
|
|
266
342
|
|
|
267
343
|
| Kind | Codes | What the SDK does |
|
|
268
344
|
|---|---|---|
|
|
269
|
-
| Fatal | `revoked`, `expired`, `hwid_mismatch` (the HWID is no longer bound to the license, for example after an HWID reset), `blocked` (HWID/IP blacklisted or not whitelisted), `session_expired` (also raised locally when the grace period or session TTL runs out), `malformed_request`, `app_disabled`, `invalid_app`, `signature_mismatch` | Clears the stored session (as `logout()` does) and stops background checks **before** calling `on_failure`, so the grace period cannot keep the app running on it. `on_failure` may call `login()` again. |
|
|
345
|
+
| Fatal | `revoked`, `expired`, `hwid_mismatch` (the HWID is no longer bound to the license, for example after an HWID reset), `blocked` (HWID/IP blacklisted or not whitelisted), `session_expired` (also raised locally when the grace period or session TTL runs out), `malformed_request`, `app_disabled`, `invalid_app`, `signature_mismatch` (plus the `activate_offline()`-only codes `offline_activation_*` and `offline_file_rejected`) | Clears the stored session (as `logout()` does) and stops background checks **before** calling `on_failure`, so the grace period cannot keep the app running on it. `on_failure` may call `login()` again. |
|
|
270
346
|
| Transient | Everything else: `network_error`, `timeout`, `rate_limited`, `system_error`, `no_credits`, `demo_quota_exceeded`, `app_burn_cap_reached`, `bad_request`, `invalid_key`, every `http_error_<status>`, `invalid_json_response`, `unexpected_response`, `nonce_mismatch`, any unrecognized code | Keeps the session and checks in again on the next `heartbeat_interval`. Once the signed session's TTL has passed, the next transient failure is reported as a fatal `session_expired` instead. |
|
|
271
347
|
|
|
272
|
-
Transient failures only keep checking in if `on_failure` returns normally. Without a callback, a transient failure prints `AuthForge: background check failed (<code>); retrying next interval` to stderr and check-ins continue; a fatal one, including the `session_expired` a transient failure becomes once the session TTL has passed, still ends the process with `os._exit(1)`.
|
|
348
|
+
Transient failures only keep checking in if `on_failure` returns normally. Without a callback, a transient failure prints `AuthForge: background check failed (<code>); retrying next interval` to stderr and check-ins continue; a fatal one, including the `session_expired` a transient failure becomes once the session TTL has passed, still ends the process with `os._exit(1)`. Network failures are reported once, with code `network_error` or `timeout`: as `heartbeat_failed` from check-ins and as `login_failed` from `login()`, never as a separate `network_error` reason. `activate_offline()` and `self_ban()` raise them as `AuthForgeError` instead.
|
|
273
349
|
|
|
274
350
|
`on_failure` runs on the background heartbeat thread with no SDK lock held, so calling `logout()`, `is_authenticated()` or `login()` from it is safe. A check-in that is still in flight when you call `logout()` or `login()` is discarded: it cannot restore the old session or report a failure for it.
|
|
275
351
|
|
|
@@ -339,11 +415,12 @@ client.self_ban(
|
|
|
339
415
|
- Uses post-session mode when a session token is available (`session_token` arg or current SDK session).
|
|
340
416
|
- Falls back to pre-session mode using `license_key` + nonce + app secret.
|
|
341
417
|
- In pre-session mode, revoke is forced off client-side to avoid unsafe key revocations.
|
|
418
|
+
- Every failure raises: a server rejection as `ValueError(code)`, a network failure (after one retry) as `AuthForgeError` with `exc.code` `network_error` or `timeout`. `self_ban()` never calls `on_failure` or `os._exit`, with or without a callback.
|
|
342
419
|
- Not available after `login_from_file()`: offline sessions have no server session, so `self_ban()` with no explicit `license_key` / `session_token` raises `ValueError("offline_session")` without contacting the server.
|
|
343
420
|
|
|
344
421
|
## How It Works
|
|
345
422
|
|
|
346
|
-
1. **Activate**: `login()` uses `hwid_override` if provided; otherwise it
|
|
423
|
+
1. **Activate**: `login()` uses `hwid_override` if provided; otherwise it derives the HWID from the OS machine id (see [Hardware ID](#hardware-id)). It then 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 with a TTL. The SDK verifies the Ed25519 signature and nonce to prevent replay attacks.
|
|
347
424
|
|
|
348
425
|
2. **Background checks**: a daemon thread wakes at the configured interval. By default it enforces the grace period: it re-verifies the stored signature and checks expiry without network calls. With `online_heartbeat=True`, it instead sends `/auth/heartbeat` with a fresh nonce and verifies the response.
|
|
349
426
|
|
|
@@ -351,18 +428,29 @@ client.self_ban(
|
|
|
351
428
|
|
|
352
429
|
## Hardware ID
|
|
353
430
|
|
|
354
|
-
The
|
|
355
|
-
|
|
356
|
-
-
|
|
357
|
-
|
|
431
|
+
The HWID (v2, since 2.0) is
|
|
432
|
+
|
|
433
|
+
`SHA256("authforge-hwid-v2|" + app_id + "|" + machine_id)` as lowercase hex,
|
|
434
|
+
|
|
435
|
+
where `machine_id` is the operating system's own machine id, trimmed and lowercased:
|
|
436
|
+
|
|
437
|
+
| OS | Source |
|
|
438
|
+
| -- | ------ |
|
|
439
|
+
| macOS | `IOPlatformUUID` (`/usr/sbin/ioreg -rd1 -c IOPlatformExpertDevice`) |
|
|
440
|
+
| Linux | `/etc/machine-id`, then `/var/lib/dbus/machine-id` |
|
|
441
|
+
| Windows | `HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid` (64-bit registry view, via `winreg`) |
|
|
442
|
+
|
|
443
|
+
These ids survive network changes, renames, disk usage and USB devices. Including the app id means two different apps on the same machine get unrelated HWIDs. Every AuthForge SDK computes the same value for the same app id and machine; `hwid_vectors.json` pins this. The HWID is sent with every auth request so the server can enforce per-device license limits.
|
|
444
|
+
|
|
445
|
+
If no valid OS id can be read (for example a container without `/etc/machine-id`), the SDK falls back to `legacy:<the pre-2.0 fingerprint of MAC, CPU and disk serial>` as the machine id. Containers built from one image can also share a baked-in machine id, so for containers prefer `hwid_override` with an identifier you control.
|
|
358
446
|
|
|
359
|
-
|
|
447
|
+
The helpers `hwid_from_machine_id`, `normalize_machine_id`, `parse_ioreg_platform_uuid` and `parse_reg_query_machine_guid` are exported for tooling and tests.
|
|
360
448
|
|
|
361
449
|
For non-device identities (for example Telegram users), pass `hwid_override` such as `tg:<user_id>`.
|
|
362
450
|
|
|
363
451
|
## Test Vectors
|
|
364
452
|
|
|
365
|
-
The shared `test_vectors.json` file validates cross-language Ed25519 verification behavior. `offline_license_vectors.json` (generated from a fixed test seed in the Node SDK repo) is the cross-SDK conformance suite for `.authforge` offline license files: good files plus the `bad_signature`, wrong key, `wrong_app`, `expired`, `hwid_mismatch`, `unsupported_version` and `bad_armor` rejects.
|
|
453
|
+
The shared `test_vectors.json` file validates cross-language Ed25519 verification behavior. `offline_license_vectors.json` (generated from a fixed test seed in the Node SDK repo) is the cross-SDK conformance suite for `.authforge` offline license files: good files plus the `bad_signature`, wrong key, `wrong_app`, `expired`, `hwid_mismatch`, `unsupported_version` and `bad_armor` rejects. `self_serve_activation_vectors.json` is the cross-SDK suite for `activate_offline()`: mocked `/auth/offline/activate` responses with the expected written file, session and error code. `hwid_vectors.json` (generated by `authforge-node/generate_hwid_vectors.mjs`) is the cross-SDK suite for HWID v2: derivation, machine id normalization, and the `ioreg` / `reg query` parsers.
|
|
366
454
|
|
|
367
455
|
## Requirements
|
|
368
456
|
|
|
@@ -10,7 +10,7 @@ Uses `cryptography` for Ed25519 verification. Works on Python 3.9+.
|
|
|
10
10
|
2. **Run through the grace period.** By default the app keeps running on the signed session with no further network calls. A background check re-verifies the signature locally and fails when the session TTL expires. The grace period equals the session TTL: default 24h, and the server clamps requested values to 1h to 7d.
|
|
11
11
|
3. **Optionally enable online check-ins.** With `online_heartbeat=True`, the SDK also calls `POST /auth/heartbeat` every `heartbeat_interval` seconds for fast revocation and concurrent-use detection.
|
|
12
12
|
|
|
13
|
-
Separately, for machines that can **never** reach the internet, an operator can mint a signed **offline license file (`.authforge`)** in the AuthForge dashboard or Developer API. The SDK verifies it locally with your app public key: see [Offline license files](#offline-license-files-authforge).
|
|
13
|
+
Separately, for machines that can **never** reach the internet, an operator can mint a signed **offline license file (`.authforge`)** in the AuthForge dashboard or Developer API. The SDK verifies it locally with your app public key: see [Offline license files](#offline-license-files-authforge). If the machine can go online **once**, the end user can get that file themselves with `activate_offline()`: see [Self-serve offline activation](#self-serve-offline-activation).
|
|
14
14
|
|
|
15
15
|
## Features
|
|
16
16
|
|
|
@@ -22,8 +22,9 @@ Everything in this list ships in `authforge.py` today:
|
|
|
22
22
|
- **Grace period by default**: after one successful activation, the app runs on the signed session (no network) until the TTL expires.
|
|
23
23
|
- **Online check-ins** (opt-in): periodic `/auth/heartbeat` calls for fast revocation and concurrent-use detection.
|
|
24
24
|
- **Offline license files (`.authforge`)**: `login_from_file()` / `verify_license_file()` verify a cloud-minted, Ed25519-signed file with zero network access for air-gapped machines.
|
|
25
|
+
- **Self-serve offline activation**: `activate_offline()` turns a license key into a lifetime, HWID-bound `.authforge` file with one online call, then runs offline from that file.
|
|
25
26
|
- **Nonce anti-replay**: a fresh 128-bit nonce is sent on every request and the echoed nonce in the signed payload is checked before the response is accepted.
|
|
26
|
-
- **HWID
|
|
27
|
+
- **HWID v2**: per-app device id derived from the OS machine id (macOS `IOPlatformUUID`, Linux `/etc/machine-id`, Windows `MachineGuid`), identical across all AuthForge SDKs for the same app and machine.
|
|
27
28
|
- **`hwid_override`**: bind to any identity instead of the machine (for example `tg:<id>`, `discord:<id>`).
|
|
28
29
|
- **Seat enforcement**: the server binds each HWID into a license's free slots up to `maxHwidSlots`; `hwid_count` / `max_hwid_slots` are surfaced on the result. A shared (unlimited-seat) key skips per-device binding.
|
|
29
30
|
- **Self-ban** (`self_ban()`) for anti-tamper response, both pre-session and post-session.
|
|
@@ -90,7 +91,7 @@ client = AuthForgeClient(
|
|
|
90
91
|
| `on_failure` | callable | `None` | Callback `(reason: str, exc: Exception | None)` on auth failure |
|
|
91
92
|
| `request_timeout` | int | `15` | HTTP request timeout in seconds |
|
|
92
93
|
| `ttl_seconds` | `int \| None` | `None` (server default: 86400) | The grace period duration: how long the app keeps running on the signed session without contacting AuthForge. Server clamps to `[3600, 604800]` (1h to 7d); preserved across check-in refreshes. |
|
|
93
|
-
| `hwid_override` | `str \| None` | `None` | Optional custom hardware/subject identifier. When set to a non-empty value, the SDK uses it instead of machine
|
|
94
|
+
| `hwid_override` | `str \| None` | `None` | Optional custom hardware/subject identifier. When set to a non-empty value, the SDK uses it instead of deriving the HWID from the OS machine id. |
|
|
94
95
|
| `online_heartbeat` | bool (keyword-only) | `False` | Enable online check-ins: periodic `POST /auth/heartbeat` for fast revocation and concurrent-use detection. |
|
|
95
96
|
|
|
96
97
|
### Identity-based binding example (Telegram/Discord)
|
|
@@ -161,12 +162,81 @@ if client.login_from_file("license.authforge"):
|
|
|
161
162
|
print(client.get_license_variables())
|
|
162
163
|
```
|
|
163
164
|
|
|
164
|
-
|
|
165
|
+
Since 2.0 every AuthForge SDK computes the same HWID for the same app id and machine, but HWIDs collected before upgrading to 2.0 no longer match: collect the HWID again after upgrading. After `login_from_file()`, `get_session_kind()` returns `"offline"` (`"online"` after `login()`, `None` when logged out).
|
|
165
166
|
|
|
166
167
|
`verify_license_file()` (module function and client method) performs the same checks without touching client state. Failure codes, in check order: `bad_armor`, `bad_signature`, `unsupported_version`, `malformed_payload`, `wrong_app`, `expired`, `hwid_mismatch`. `login_from_file()` reports them through `on_failure("offline_login_failed", exc)` and returns `False`; it never calls `os._exit`.
|
|
167
168
|
|
|
168
169
|
File format (version 1): PEM-style armor with informational headers, a base64 JSON payload (`v`, `appId`, `licenseKey`, `jti`, `kid`, `issuedAt`, `expiresAt`, `hwid` policy, optional label/variable snapshots) and a detached Ed25519 signature over the UTF-8 bytes of the base64 payload string - the same contract as `/auth/validate`. See `offline_license_vectors.json` for conformance vectors.
|
|
169
170
|
|
|
171
|
+
## Self-serve offline activation
|
|
172
|
+
|
|
173
|
+
A separate mode for apps that sell a one-time license and then run with no network: the end user types the license key once, `activate_offline()` makes **one** online call (`POST /auth/offline/activate`) and gets back a lifetime `.authforge` file bound to this machine's HWID. From then on the app starts with `login_from_file()` and never contacts AuthForge again.
|
|
174
|
+
|
|
175
|
+
How it differs from air-gapped offline files:
|
|
176
|
+
|
|
177
|
+
- It **needs the App Secret**, because it is an online call. Air-gapped builds that only use `login_from_file()` still ship with `app_secret=None`.
|
|
178
|
+
- The app owner opts the app in (app settings in the dashboard). Only **perpetual**, **seat-limited** licenses can self-serve; each license can produce files for at most `maxHwidSlots` distinct machines, ever. HWID resets do not free those machine slots.
|
|
179
|
+
- **Billing:** a new machine costs the app owner **2 credits**, once. The same machine activating again (reinstall, deleted file) gets the identical file back for **free**. A refused activation costs nothing.
|
|
180
|
+
- **A refund or chargeback revokes the license online but cannot reach files already issued.** A lifetime file keeps working on its machine.
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
import os
|
|
184
|
+
import sys
|
|
185
|
+
|
|
186
|
+
from authforge import AuthForgeClient, AuthForgeError
|
|
187
|
+
|
|
188
|
+
LICENSE_PATH = os.path.join(os.path.expanduser("~"), ".myapp", "license.authforge")
|
|
189
|
+
|
|
190
|
+
client = AuthForgeClient(
|
|
191
|
+
app_id="YOUR_APP_ID",
|
|
192
|
+
app_secret="YOUR_APP_SECRET", # required for activate_offline (online call)
|
|
193
|
+
public_key="YOUR_PUBLIC_KEY",
|
|
194
|
+
on_failure=lambda reason, exc: None, # login_from_file reports why it failed here
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# 1. No network, no credits: run from the file if it is there and valid.
|
|
198
|
+
if not (os.path.exists(LICENSE_PATH) and client.login_from_file(LICENSE_PATH)):
|
|
199
|
+
# 2. No file yet, or it fails (e.g. hwid_mismatch after a hardware change).
|
|
200
|
+
key = input("License key: ").strip()
|
|
201
|
+
try:
|
|
202
|
+
client.activate_offline(key, path=LICENSE_PATH)
|
|
203
|
+
except AuthForgeError as exc:
|
|
204
|
+
if exc.code == "offline_activation_limit_reached":
|
|
205
|
+
print("This license is active on its maximum number of machines. Please contact the vendor.")
|
|
206
|
+
elif exc.code in {"invalid_key", "revoked", "expired"}:
|
|
207
|
+
print("That license key is not valid.")
|
|
208
|
+
elif exc.code == "hwid_mismatch":
|
|
209
|
+
print("No free seat on this license for this machine.")
|
|
210
|
+
elif exc.transient:
|
|
211
|
+
print(f"Could not activate right now ({exc.code}). Check your connection and try again.")
|
|
212
|
+
else:
|
|
213
|
+
print(f"Activation failed: {exc.code}")
|
|
214
|
+
sys.exit(1)
|
|
215
|
+
|
|
216
|
+
print("Licensed:", client.get_license_variables())
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
`activate_offline(license_key, path=None)` returns `True` on success and raises `AuthForgeError` otherwise. It:
|
|
220
|
+
|
|
221
|
+
1. Fails fast with the usual "app_secret is required" `ValueError` (no request) when the client has no App Secret.
|
|
222
|
+
2. Sends `appId`, `appSecret`, `licenseKey`, the same HWID `login()` sends and a fresh nonce, with the same retries as `login()` (`rate_limited` after 2s then 5s, one retry on a network error).
|
|
223
|
+
3. Verifies the returned file with your public key(s), app id and this machine's HWID **before** writing anything. A file that fails raises `offline_file_rejected`; the message includes the verifier's reason (`bad_signature`, `wrong_app`, `hwid_mismatch`, ...).
|
|
224
|
+
4. If `path` is set, writes the file byte-for-byte and atomically (temp file in the same directory, then rename), creating parent directories.
|
|
225
|
+
5. Leaves the client authenticated exactly as `login_from_file()` does: `get_session_kind()` is `"offline"`, variables and `get_offline_license()` are set, no background checks start. `get_offline_license()["replay"]` is `True` when the server returned a file this machine already had.
|
|
226
|
+
|
|
227
|
+
On any failure nothing is written to `path` (an existing file there is left as it was), the call does not authenticate the client (an existing session is kept), and neither `on_failure` nor `os._exit` is called.
|
|
228
|
+
|
|
229
|
+
| Code | Kind | Meaning |
|
|
230
|
+
| --- | --- | --- |
|
|
231
|
+
| `offline_activation_disabled` | fatal | The app has not opted in to self-serve activation |
|
|
232
|
+
| `offline_activation_requires_perpetual` | fatal | The license has an expiry; only perpetual licenses can self-serve |
|
|
233
|
+
| `offline_activation_requires_seats` | fatal | Unlimited-seat (shared) licenses cannot self-serve |
|
|
234
|
+
| `offline_activation_limit_reached` | fatal | Every machine slot on the license is used; the buyer must contact the vendor |
|
|
235
|
+
| `offline_file_rejected` | fatal | SDK code: the returned file failed local verification; nothing was written |
|
|
236
|
+
| `hwid_mismatch` | fatal | No free seat on the license for this machine (server code, not the verifier's) |
|
|
237
|
+
| `file_write_failed` | transient | The verified file could not be written to `path`; activating again is free |
|
|
238
|
+
| `invalid_key`, `revoked`, `expired`, `no_credits`, `rate_limited`, `unexpected_response`, `network_error`, ... | as everywhere else | Existing codes keep their meaning and classification |
|
|
239
|
+
|
|
170
240
|
## Migrating from heartbeat_mode
|
|
171
241
|
|
|
172
242
|
Earlier releases required `heartbeat_mode="LOCAL"` or `"SERVER"`. The argument is now optional and deprecated; it still works but emits a `DeprecationWarning`.
|
|
@@ -189,6 +259,7 @@ For code that still reads it, `client.heartbeat_mode` remains available and refl
|
|
|
189
259
|
|
|
190
260
|
- **1 `login()` or `validate_license()` call = 1 credit** (one `/auth/validate` debit each).
|
|
191
261
|
- **10 online check-ins on the same license = 1 credit** (billed every 10th successful `/auth/heartbeat`). Grace period checks are local and free.
|
|
262
|
+
- **1 new offline activation (`activate_offline()`) = 2 credits**, charged to the app owner; re-activating the same machine is free. `login_from_file()` is always free.
|
|
192
263
|
|
|
193
264
|
A desktop app running 6h/day with online check-ins at a 15-minute interval burns ~3-4 credits/day. The server enforces `/auth/heartbeat` at 6 requests/minute per license key, so keep intervals at 10 seconds or higher and pick the interval based on how fast you need revocations to propagate (they always land on the **next** check-in).
|
|
194
265
|
|
|
@@ -198,8 +269,9 @@ A desktop app running 6h/day with online check-ins at a 15-minute interval burns
|
|
|
198
269
|
|---|---|---|
|
|
199
270
|
| `login(license_key)` | `bool` | Activates: validates the key online and stores the signed session (`sessionToken`, `expiresIn`, `appVariables`, `licenseVariables`) |
|
|
200
271
|
| `validate_license(license_key)` | `ValidateLicenseResult` | Same `/auth/validate` + signatures as `login`; does not store session or start background checks; returns a dict with `valid` / `code` and **never** calls `on_failure` or `os._exit` |
|
|
201
|
-
| `self_ban(...)` | `dict` | Requests `/auth/selfban` to blacklist HWID/IP and optionally revoke (session-authenticated only) |
|
|
272
|
+
| `self_ban(...)` | `dict` | Requests `/auth/selfban` to blacklist HWID/IP and optionally revoke (session-authenticated only). Raises `ValueError` on any failure (a network failure as `AuthForgeError` `network_error` / `timeout`); never calls `on_failure` or `os._exit` |
|
|
202
273
|
| `login_from_file(path_or_text)` | `bool` | Authorizes from an offline `.authforge` file with no network; never starts background checks; failures go to `on_failure("offline_login_failed", …)` |
|
|
274
|
+
| `activate_offline(license_key, path=None)` | `bool` | Self-serve offline activation: one online call returns a lifetime HWID-bound `.authforge` file, verified locally, written atomically to `path` and applied like `login_from_file`. Needs the App Secret; raises `AuthForgeError` on refusal; never calls `on_failure` or `os._exit` |
|
|
203
275
|
| `verify_license_file(path_or_text, *, now=None)` | `VerifyLicenseFileResult` | Verifies a `.authforge` file with this client's app id / keys / HWID without changing state |
|
|
204
276
|
| `get_offline_license()` | `dict \| None` | Metadata of the offline file in use (`jti`, `expires_at`, `hwid_policy`, …) |
|
|
205
277
|
| `get_session_kind()` | `"online" \| "offline" \| None` | Which kind of session the client holds (`None` when logged out) |
|
|
@@ -219,7 +291,11 @@ If authentication fails (activation rejected, check-in fails, signature mismatch
|
|
|
219
291
|
**`validate_license()`** does not trigger `on_failure` or `os._exit`: check `result["valid"]` and `result["code"]`.
|
|
220
292
|
|
|
221
293
|
Recognized server errors (`KNOWN_SERVER_ERRORS`):
|
|
222
|
-
`invalid_app`, `invalid_key`, `expired`, `revoked`, `hwid_mismatch`, `no_credits`, `app_burn_cap_reached`, `blocked`, `rate_limited`, `replay_detected`, `app_disabled`, `session_expired`, `revoke_requires_session`, `bad_request`, `malformed_request`, `demo_quota_exceeded`, `system_error`. Codes added to the server later are passed through unchanged.
|
|
294
|
+
`invalid_app`, `invalid_key`, `expired`, `revoked`, `hwid_mismatch`, `no_credits`, `app_burn_cap_reached`, `blocked`, `rate_limited`, `replay_detected`, `app_disabled`, `session_expired`, `revoke_requires_session`, `bad_request`, `malformed_request`, `demo_quota_exceeded`, `system_error`, and from `activate_offline()`: `offline_activation_disabled`, `offline_activation_requires_perpetual`, `offline_activation_requires_seats`, `offline_activation_limit_reached`, plus the SDK code `offline_file_rejected`. Codes added to the server later are passed through unchanged.
|
|
295
|
+
|
|
296
|
+
**`activate_offline()`** does not trigger `on_failure` or `os._exit` either: it raises `AuthForgeError` (see [Self-serve offline activation](#self-serve-offline-activation)).
|
|
297
|
+
|
|
298
|
+
**`self_ban()`** does not trigger `on_failure` or `os._exit` either: every failure raises, a server rejection as `ValueError(code)` and a network failure as `AuthForgeError` with `exc.code` `network_error` or `timeout`.
|
|
223
299
|
|
|
224
300
|
Request retries are automatic inside the internal HTTP layer:
|
|
225
301
|
- `rate_limited`, or HTTP 429 with no error code: retry after 2s, then 5s (max 3 attempts total)
|
|
@@ -238,10 +314,10 @@ A failed check-in only counts as an AuthForge verdict when the body is a JSON ob
|
|
|
238
314
|
|
|
239
315
|
| Kind | Codes | What the SDK does |
|
|
240
316
|
|---|---|---|
|
|
241
|
-
| Fatal | `revoked`, `expired`, `hwid_mismatch` (the HWID is no longer bound to the license, for example after an HWID reset), `blocked` (HWID/IP blacklisted or not whitelisted), `session_expired` (also raised locally when the grace period or session TTL runs out), `malformed_request`, `app_disabled`, `invalid_app`, `signature_mismatch` | Clears the stored session (as `logout()` does) and stops background checks **before** calling `on_failure`, so the grace period cannot keep the app running on it. `on_failure` may call `login()` again. |
|
|
317
|
+
| Fatal | `revoked`, `expired`, `hwid_mismatch` (the HWID is no longer bound to the license, for example after an HWID reset), `blocked` (HWID/IP blacklisted or not whitelisted), `session_expired` (also raised locally when the grace period or session TTL runs out), `malformed_request`, `app_disabled`, `invalid_app`, `signature_mismatch` (plus the `activate_offline()`-only codes `offline_activation_*` and `offline_file_rejected`) | Clears the stored session (as `logout()` does) and stops background checks **before** calling `on_failure`, so the grace period cannot keep the app running on it. `on_failure` may call `login()` again. |
|
|
242
318
|
| Transient | Everything else: `network_error`, `timeout`, `rate_limited`, `system_error`, `no_credits`, `demo_quota_exceeded`, `app_burn_cap_reached`, `bad_request`, `invalid_key`, every `http_error_<status>`, `invalid_json_response`, `unexpected_response`, `nonce_mismatch`, any unrecognized code | Keeps the session and checks in again on the next `heartbeat_interval`. Once the signed session's TTL has passed, the next transient failure is reported as a fatal `session_expired` instead. |
|
|
243
319
|
|
|
244
|
-
Transient failures only keep checking in if `on_failure` returns normally. Without a callback, a transient failure prints `AuthForge: background check failed (<code>); retrying next interval` to stderr and check-ins continue; a fatal one, including the `session_expired` a transient failure becomes once the session TTL has passed, still ends the process with `os._exit(1)`.
|
|
320
|
+
Transient failures only keep checking in if `on_failure` returns normally. Without a callback, a transient failure prints `AuthForge: background check failed (<code>); retrying next interval` to stderr and check-ins continue; a fatal one, including the `session_expired` a transient failure becomes once the session TTL has passed, still ends the process with `os._exit(1)`. Network failures are reported once, with code `network_error` or `timeout`: as `heartbeat_failed` from check-ins and as `login_failed` from `login()`, never as a separate `network_error` reason. `activate_offline()` and `self_ban()` raise them as `AuthForgeError` instead.
|
|
245
321
|
|
|
246
322
|
`on_failure` runs on the background heartbeat thread with no SDK lock held, so calling `logout()`, `is_authenticated()` or `login()` from it is safe. A check-in that is still in flight when you call `logout()` or `login()` is discarded: it cannot restore the old session or report a failure for it.
|
|
247
323
|
|
|
@@ -311,11 +387,12 @@ client.self_ban(
|
|
|
311
387
|
- Uses post-session mode when a session token is available (`session_token` arg or current SDK session).
|
|
312
388
|
- Falls back to pre-session mode using `license_key` + nonce + app secret.
|
|
313
389
|
- In pre-session mode, revoke is forced off client-side to avoid unsafe key revocations.
|
|
390
|
+
- Every failure raises: a server rejection as `ValueError(code)`, a network failure (after one retry) as `AuthForgeError` with `exc.code` `network_error` or `timeout`. `self_ban()` never calls `on_failure` or `os._exit`, with or without a callback.
|
|
314
391
|
- Not available after `login_from_file()`: offline sessions have no server session, so `self_ban()` with no explicit `license_key` / `session_token` raises `ValueError("offline_session")` without contacting the server.
|
|
315
392
|
|
|
316
393
|
## How It Works
|
|
317
394
|
|
|
318
|
-
1. **Activate**: `login()` uses `hwid_override` if provided; otherwise it
|
|
395
|
+
1. **Activate**: `login()` uses `hwid_override` if provided; otherwise it derives the HWID from the OS machine id (see [Hardware ID](#hardware-id)). It then 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 with a TTL. The SDK verifies the Ed25519 signature and nonce to prevent replay attacks.
|
|
319
396
|
|
|
320
397
|
2. **Background checks**: a daemon thread wakes at the configured interval. By default it enforces the grace period: it re-verifies the stored signature and checks expiry without network calls. With `online_heartbeat=True`, it instead sends `/auth/heartbeat` with a fresh nonce and verifies the response.
|
|
321
398
|
|
|
@@ -323,18 +400,29 @@ client.self_ban(
|
|
|
323
400
|
|
|
324
401
|
## Hardware ID
|
|
325
402
|
|
|
326
|
-
The
|
|
327
|
-
|
|
328
|
-
-
|
|
329
|
-
|
|
403
|
+
The HWID (v2, since 2.0) is
|
|
404
|
+
|
|
405
|
+
`SHA256("authforge-hwid-v2|" + app_id + "|" + machine_id)` as lowercase hex,
|
|
406
|
+
|
|
407
|
+
where `machine_id` is the operating system's own machine id, trimmed and lowercased:
|
|
408
|
+
|
|
409
|
+
| OS | Source |
|
|
410
|
+
| -- | ------ |
|
|
411
|
+
| macOS | `IOPlatformUUID` (`/usr/sbin/ioreg -rd1 -c IOPlatformExpertDevice`) |
|
|
412
|
+
| Linux | `/etc/machine-id`, then `/var/lib/dbus/machine-id` |
|
|
413
|
+
| Windows | `HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid` (64-bit registry view, via `winreg`) |
|
|
414
|
+
|
|
415
|
+
These ids survive network changes, renames, disk usage and USB devices. Including the app id means two different apps on the same machine get unrelated HWIDs. Every AuthForge SDK computes the same value for the same app id and machine; `hwid_vectors.json` pins this. The HWID is sent with every auth request so the server can enforce per-device license limits.
|
|
416
|
+
|
|
417
|
+
If no valid OS id can be read (for example a container without `/etc/machine-id`), the SDK falls back to `legacy:<the pre-2.0 fingerprint of MAC, CPU and disk serial>` as the machine id. Containers built from one image can also share a baked-in machine id, so for containers prefer `hwid_override` with an identifier you control.
|
|
330
418
|
|
|
331
|
-
|
|
419
|
+
The helpers `hwid_from_machine_id`, `normalize_machine_id`, `parse_ioreg_platform_uuid` and `parse_reg_query_machine_guid` are exported for tooling and tests.
|
|
332
420
|
|
|
333
421
|
For non-device identities (for example Telegram users), pass `hwid_override` such as `tg:<user_id>`.
|
|
334
422
|
|
|
335
423
|
## Test Vectors
|
|
336
424
|
|
|
337
|
-
The shared `test_vectors.json` file validates cross-language Ed25519 verification behavior. `offline_license_vectors.json` (generated from a fixed test seed in the Node SDK repo) is the cross-SDK conformance suite for `.authforge` offline license files: good files plus the `bad_signature`, wrong key, `wrong_app`, `expired`, `hwid_mismatch`, `unsupported_version` and `bad_armor` rejects.
|
|
425
|
+
The shared `test_vectors.json` file validates cross-language Ed25519 verification behavior. `offline_license_vectors.json` (generated from a fixed test seed in the Node SDK repo) is the cross-SDK conformance suite for `.authforge` offline license files: good files plus the `bad_signature`, wrong key, `wrong_app`, `expired`, `hwid_mismatch`, `unsupported_version` and `bad_armor` rejects. `self_serve_activation_vectors.json` is the cross-SDK suite for `activate_offline()`: mocked `/auth/offline/activate` responses with the expected written file, session and error code. `hwid_vectors.json` (generated by `authforge-node/generate_hwid_vectors.mjs`) is the cross-SDK suite for HWID v2: derivation, machine id normalization, and the `ioreg` / `reg query` parsers.
|
|
338
426
|
|
|
339
427
|
## Requirements
|
|
340
428
|
|
|
@@ -88,7 +88,7 @@ _ACTIVATION_REQUEST_VERSION = 1
|
|
|
88
88
|
_ACTIVATION_REQUEST_TYP = "authforge-activation-request"
|
|
89
89
|
_BEGIN_ACTIVATION_REQUEST = "-----BEGIN AUTHFORGE ACTIVATION REQUEST-----"
|
|
90
90
|
_END_ACTIVATION_REQUEST = "-----END AUTHFORGE ACTIVATION REQUEST-----"
|
|
91
|
-
_SDK_TAG = "python/
|
|
91
|
+
_SDK_TAG = "python/2.0.0"
|
|
92
92
|
_ARMOR_LINE_WIDTH = 64
|
|
93
93
|
_MAX_REQUEST_HWID = 256
|
|
94
94
|
_MAX_REQUEST_MACHINE_NAME = 128
|
|
@@ -477,6 +477,33 @@ def _verify_offline_signature(payload_base64: str, signature_base64: str, keys:
|
|
|
477
477
|
return False
|
|
478
478
|
|
|
479
479
|
|
|
480
|
+
def _write_file_atomic(path: str, data: bytes) -> None:
|
|
481
|
+
"""Write ``data`` byte-for-byte to ``path`` via a temp file in the same
|
|
482
|
+
directory and ``os.replace``, creating parent directories. A failure never
|
|
483
|
+
leaves a partial file at ``path`` or changes an existing one."""
|
|
484
|
+
directory = os.path.dirname(os.path.abspath(path))
|
|
485
|
+
os.makedirs(directory, exist_ok=True)
|
|
486
|
+
tmp_path = os.path.join(
|
|
487
|
+
directory, f".{os.path.basename(path)}.{secrets.token_hex(8)}.tmp"
|
|
488
|
+
)
|
|
489
|
+
# O_BINARY (Windows only) keeps the bytes exact; mode 0o666 lets the umask
|
|
490
|
+
# apply as for a normal open().
|
|
491
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0)
|
|
492
|
+
fd = os.open(tmp_path, flags, 0o666)
|
|
493
|
+
try:
|
|
494
|
+
with os.fdopen(fd, "wb") as handle:
|
|
495
|
+
handle.write(data)
|
|
496
|
+
handle.flush()
|
|
497
|
+
os.fsync(handle.fileno())
|
|
498
|
+
os.replace(tmp_path, path)
|
|
499
|
+
except BaseException:
|
|
500
|
+
try:
|
|
501
|
+
os.remove(tmp_path)
|
|
502
|
+
except OSError:
|
|
503
|
+
pass
|
|
504
|
+
raise
|
|
505
|
+
|
|
506
|
+
|
|
480
507
|
KNOWN_SERVER_ERRORS = {
|
|
481
508
|
"invalid_app",
|
|
482
509
|
"invalid_key",
|
|
@@ -495,6 +522,14 @@ KNOWN_SERVER_ERRORS = {
|
|
|
495
522
|
"malformed_request",
|
|
496
523
|
"demo_quota_exceeded",
|
|
497
524
|
"system_error",
|
|
525
|
+
# POST /auth/offline/activate (self-serve offline activation).
|
|
526
|
+
"offline_activation_disabled",
|
|
527
|
+
"offline_activation_requires_perpetual",
|
|
528
|
+
"offline_activation_requires_seats",
|
|
529
|
+
"offline_activation_limit_reached",
|
|
530
|
+
# SDK code (never sent by the server): the returned file failed local
|
|
531
|
+
# verification. Listed with the server codes for cross-SDK parity.
|
|
532
|
+
"offline_file_rejected",
|
|
498
533
|
}
|
|
499
534
|
|
|
500
535
|
# Well-formed AuthForge verdicts that end the session. This is the only list
|
|
@@ -510,6 +545,12 @@ DEFINITIVE_ERROR_CODES = frozenset(
|
|
|
510
545
|
"app_disabled",
|
|
511
546
|
"invalid_app",
|
|
512
547
|
"signature_mismatch",
|
|
548
|
+
"offline_activation_disabled",
|
|
549
|
+
"offline_activation_requires_perpetual",
|
|
550
|
+
"offline_activation_requires_seats",
|
|
551
|
+
"offline_activation_limit_reached",
|
|
552
|
+
# SDK code: activate_offline() got a file that failed local verification.
|
|
553
|
+
"offline_file_rejected",
|
|
513
554
|
}
|
|
514
555
|
)
|
|
515
556
|
|
|
@@ -536,6 +577,9 @@ TRANSIENT_ERROR_CODES = frozenset(
|
|
|
536
577
|
"missing_session_token",
|
|
537
578
|
"nonce_mismatch",
|
|
538
579
|
"unknown_error",
|
|
580
|
+
# activate_offline() could not write the verified file; activating the
|
|
581
|
+
# same machine again is free.
|
|
582
|
+
"file_write_failed",
|
|
539
583
|
}
|
|
540
584
|
)
|
|
541
585
|
_SERVER_ERROR_CODE_RE = re.compile(r"[a-z][a-z0-9_]{0,63}")
|
|
@@ -577,6 +621,100 @@ def is_transient_error(error: Union[str, BaseException, None]) -> bool:
|
|
|
577
621
|
return code not in DEFINITIVE_ERROR_CODES
|
|
578
622
|
|
|
579
623
|
|
|
624
|
+
# HWID v2: sha256("authforge-hwid-v2|" + app_id + "|" + machine_id), where machine_id is
|
|
625
|
+
# the OS machine id (macOS IOPlatformUUID, Linux /etc/machine-id, Windows MachineGuid).
|
|
626
|
+
# Every SDK derives the same value; conformance is pinned by hwid_vectors.json.
|
|
627
|
+
HWID_V2_PREFIX = "authforge-hwid-v2|"
|
|
628
|
+
_MACHINE_ID_COMMAND_TIMEOUT = 2
|
|
629
|
+
_LINUX_MACHINE_ID_PATHS = ("/etc/machine-id", "/var/lib/dbus/machine-id")
|
|
630
|
+
_MACHINE_ID_RE = re.compile(r"[0-9a-f-]+")
|
|
631
|
+
_ALL_ZERO_MACHINE_ID_RE = re.compile(r"[0-]+")
|
|
632
|
+
_IOREG_PLATFORM_UUID_RE = re.compile(r'"IOPlatformUUID"\s*=\s*"([^"]+)"')
|
|
633
|
+
_ASCII_WS_SPLIT_RE = re.compile(r"[ \t\r\n]+")
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def hwid_from_machine_id(app_id: str, machine_id: str) -> str:
|
|
637
|
+
"""Derive the HWID from an already-normalized machine id (or a ``legacy:`` fallback id)."""
|
|
638
|
+
material = f"{HWID_V2_PREFIX}{app_id}|{machine_id}"
|
|
639
|
+
return hashlib.sha256(material.encode("utf-8")).hexdigest()
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def normalize_machine_id(raw: Optional[str]) -> Optional[str]:
|
|
643
|
+
"""Trim and lowercase an OS machine id; ``None`` when it is not a usable id."""
|
|
644
|
+
if not isinstance(raw, str):
|
|
645
|
+
return None
|
|
646
|
+
value = raw.strip(" \t\r\n")
|
|
647
|
+
# ASCII-only lowercasing; the character check below rejects anything non-ASCII.
|
|
648
|
+
value = "".join(chr(ord(c) + 32) if "A" <= c <= "Z" else c for c in value)
|
|
649
|
+
if len(value) < 8 or len(value) > 128:
|
|
650
|
+
return None
|
|
651
|
+
if not _MACHINE_ID_RE.fullmatch(value):
|
|
652
|
+
return None
|
|
653
|
+
if _ALL_ZERO_MACHINE_ID_RE.fullmatch(value):
|
|
654
|
+
return None
|
|
655
|
+
return value
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def parse_ioreg_platform_uuid(output: Optional[str]) -> Optional[str]:
|
|
659
|
+
"""Extract IOPlatformUUID from ``ioreg -rd1 -c IOPlatformExpertDevice`` output."""
|
|
660
|
+
if not isinstance(output, str):
|
|
661
|
+
return None
|
|
662
|
+
match = _IOREG_PLATFORM_UUID_RE.search(output)
|
|
663
|
+
return match.group(1) if match else None
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def parse_reg_query_machine_guid(output: Optional[str]) -> Optional[str]:
|
|
667
|
+
"""Extract MachineGuid from ``reg.exe query ... /v MachineGuid`` output."""
|
|
668
|
+
if not isinstance(output, str):
|
|
669
|
+
return None
|
|
670
|
+
for line in re.split(r"\r?\n", output):
|
|
671
|
+
tokens = [t for t in _ASCII_WS_SPLIT_RE.split(line) if t]
|
|
672
|
+
if len(tokens) >= 3 and tokens[0].lower() == "machineguid" and tokens[1] == "REG_SZ":
|
|
673
|
+
return tokens[2]
|
|
674
|
+
return None
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _read_os_machine_id() -> Optional[str]:
|
|
678
|
+
"""The normalized OS machine id, or ``None``. Never raises."""
|
|
679
|
+
try:
|
|
680
|
+
if sys.platform == "darwin":
|
|
681
|
+
result = subprocess.run(
|
|
682
|
+
["/usr/sbin/ioreg", "-rd1", "-c", "IOPlatformExpertDevice"],
|
|
683
|
+
capture_output=True,
|
|
684
|
+
text=True,
|
|
685
|
+
timeout=_MACHINE_ID_COMMAND_TIMEOUT,
|
|
686
|
+
)
|
|
687
|
+
if result.returncode != 0:
|
|
688
|
+
return None
|
|
689
|
+
return normalize_machine_id(parse_ioreg_platform_uuid(result.stdout))
|
|
690
|
+
if sys.platform.startswith("linux"):
|
|
691
|
+
for path in _LINUX_MACHINE_ID_PATHS:
|
|
692
|
+
try:
|
|
693
|
+
with open(path, "r", encoding="utf-8", errors="replace") as handle:
|
|
694
|
+
machine_id = normalize_machine_id(handle.read())
|
|
695
|
+
except Exception:
|
|
696
|
+
continue # missing or unreadable: try the next source
|
|
697
|
+
if machine_id:
|
|
698
|
+
return machine_id
|
|
699
|
+
return None
|
|
700
|
+
if sys.platform == "win32":
|
|
701
|
+
import winreg
|
|
702
|
+
|
|
703
|
+
with winreg.OpenKey(
|
|
704
|
+
winreg.HKEY_LOCAL_MACHINE,
|
|
705
|
+
r"SOFTWARE\Microsoft\Cryptography",
|
|
706
|
+
0,
|
|
707
|
+
winreg.KEY_READ | winreg.KEY_WOW64_64KEY,
|
|
708
|
+
) as key:
|
|
709
|
+
value, value_type = winreg.QueryValueEx(key, "MachineGuid")
|
|
710
|
+
if value_type != winreg.REG_SZ or not isinstance(value, str):
|
|
711
|
+
return None
|
|
712
|
+
return normalize_machine_id(value)
|
|
713
|
+
except Exception:
|
|
714
|
+
pass # fall through to the legacy fingerprint
|
|
715
|
+
return None
|
|
716
|
+
|
|
717
|
+
|
|
580
718
|
def _warn(message: str) -> None:
|
|
581
719
|
# sys.stderr is None under pythonw, and a closed stream must not stop check-ins.
|
|
582
720
|
stream = sys.stderr
|
|
@@ -769,6 +907,70 @@ class AuthForgeClient:
|
|
|
769
907
|
self._apply_offline_license(result)
|
|
770
908
|
return True
|
|
771
909
|
|
|
910
|
+
def activate_offline(self, license_key: str, path: Optional[str] = None) -> bool:
|
|
911
|
+
"""Self-serve offline activation: turn a license key into a lifetime,
|
|
912
|
+
HWID-bound ``.authforge`` file with one online call
|
|
913
|
+
(``POST /auth/offline/activate``).
|
|
914
|
+
|
|
915
|
+
Needs the App Secret (unlike air-gapped builds). The server charges the
|
|
916
|
+
app owner 2 credits for a new machine; the same machine activating
|
|
917
|
+
again gets the identical file back for free. The returned file is
|
|
918
|
+
verified locally (signature, app id, this client's HWID) before
|
|
919
|
+
anything is written. When ``path`` is set the file is written there
|
|
920
|
+
atomically, creating parent directories. On success the client is
|
|
921
|
+
authenticated exactly as after :meth:`login_from_file`, and
|
|
922
|
+
``get_offline_license()["replay"]`` tells whether the server returned
|
|
923
|
+
a file this machine already had.
|
|
924
|
+
|
|
925
|
+
Raises :class:`AuthForgeError` on any refusal: ``exc.code`` is the
|
|
926
|
+
server code, ``offline_file_rejected`` (the message carries the
|
|
927
|
+
verifier's reason), ``file_write_failed`` or a network code. Never
|
|
928
|
+
calls ``on_failure`` or ``os._exit``. On failure nothing is written to
|
|
929
|
+
``path`` and the client is not authenticated by this call; an existing
|
|
930
|
+
session is kept. On success the session is replaced, as with
|
|
931
|
+
:meth:`login_from_file`.
|
|
932
|
+
"""
|
|
933
|
+
if not license_key or not isinstance(license_key, str):
|
|
934
|
+
raise ValueError("license_key must be a non-empty string")
|
|
935
|
+
self._require_app_secret()
|
|
936
|
+
target = os.fspath(path) if path else None
|
|
937
|
+
body: Dict[str, Any] = {
|
|
938
|
+
"appId": self.app_id,
|
|
939
|
+
"appSecret": self.app_secret,
|
|
940
|
+
"licenseKey": license_key,
|
|
941
|
+
"hwid": self._hwid,
|
|
942
|
+
"nonce": self._generate_nonce(),
|
|
943
|
+
}
|
|
944
|
+
try:
|
|
945
|
+
# A network failure must raise here, never reach on_failure / os._exit.
|
|
946
|
+
response_obj = self._post_json("/auth/offline/activate", body)
|
|
947
|
+
except AuthForgeError:
|
|
948
|
+
raise
|
|
949
|
+
except Exception as exc:
|
|
950
|
+
raise AuthForgeError("network_error", f"network_error: {exc}") from exc
|
|
951
|
+
if not self._is_success_status(response_obj.get("status")):
|
|
952
|
+
raise self._heartbeat_verdict(response_obj)
|
|
953
|
+
file_text = response_obj.get("file")
|
|
954
|
+
if not isinstance(file_text, str) or not file_text:
|
|
955
|
+
raise AuthForgeError("unexpected_response", "unexpected_response: missing file")
|
|
956
|
+
|
|
957
|
+
# The verifier's reason goes in the message only: the server's
|
|
958
|
+
# hwid_mismatch means "no free seat" and must stay distinguishable.
|
|
959
|
+
result = verify_license_file(file_text, self.app_id, self.public_keys, self._hwid)
|
|
960
|
+
if not result["ok"]:
|
|
961
|
+
raise AuthForgeError(
|
|
962
|
+
"offline_file_rejected", f"offline_file_rejected: {result['error']}"
|
|
963
|
+
)
|
|
964
|
+
|
|
965
|
+
if target is not None:
|
|
966
|
+
try:
|
|
967
|
+
_write_file_atomic(target, file_text.encode("utf-8"))
|
|
968
|
+
except OSError as exc:
|
|
969
|
+
raise AuthForgeError("file_write_failed", f"file_write_failed: {exc}") from exc
|
|
970
|
+
|
|
971
|
+
self._apply_offline_license(result, replay=response_obj.get("replay") is True)
|
|
972
|
+
return True
|
|
973
|
+
|
|
772
974
|
def verify_license_file(
|
|
773
975
|
self,
|
|
774
976
|
path_or_text: str,
|
|
@@ -798,7 +1000,9 @@ class AuthForgeClient:
|
|
|
798
1000
|
with open(path_or_text, "r", encoding="utf-8") as handle:
|
|
799
1001
|
return handle.read()
|
|
800
1002
|
|
|
801
|
-
def _apply_offline_license(
|
|
1003
|
+
def _apply_offline_license(
|
|
1004
|
+
self, result: VerifyLicenseFileSuccess, *, replay: Optional[bool] = None
|
|
1005
|
+
) -> None:
|
|
802
1006
|
# Stop any online session first so the two modes never overlap.
|
|
803
1007
|
self.logout()
|
|
804
1008
|
lic = result["license"]
|
|
@@ -830,6 +1034,10 @@ class AuthForgeClient:
|
|
|
830
1034
|
summary["label"] = lic["label"]
|
|
831
1035
|
if "license_expires_at" in lic:
|
|
832
1036
|
summary["license_expires_at"] = lic["license_expires_at"]
|
|
1037
|
+
if replay is not None:
|
|
1038
|
+
# Only set by activate_offline(): True when the server returned
|
|
1039
|
+
# the file this machine already had (free).
|
|
1040
|
+
summary["replay"] = replay
|
|
833
1041
|
self._offline_license = summary
|
|
834
1042
|
self._authenticated = True
|
|
835
1043
|
|
|
@@ -875,7 +1083,7 @@ class AuthForgeClient:
|
|
|
875
1083
|
}
|
|
876
1084
|
if self.ttl_seconds is not None:
|
|
877
1085
|
body["ttlSeconds"] = self.ttl_seconds
|
|
878
|
-
response_obj = self._post_json("/auth/validate", body
|
|
1086
|
+
response_obj = self._post_json("/auth/validate", body)
|
|
879
1087
|
expected_nonce = str(body.get("nonce", "")).strip()
|
|
880
1088
|
parsed = self._parse_validate_success(response_obj, expected_nonce)
|
|
881
1089
|
result: ValidateLicenseSuccess = {
|
|
@@ -934,6 +1142,8 @@ class AuthForgeClient:
|
|
|
934
1142
|
|
|
935
1143
|
resolved_session = resolved_session or current_session
|
|
936
1144
|
|
|
1145
|
+
# Every failure raises, a network failure as AuthForgeError
|
|
1146
|
+
# network_error / timeout; self_ban never calls on_failure / os._exit.
|
|
937
1147
|
if resolved_session:
|
|
938
1148
|
body: Dict[str, Any] = {
|
|
939
1149
|
"appId": self.app_id,
|
|
@@ -1066,7 +1276,7 @@ class AuthForgeClient:
|
|
|
1066
1276
|
"hwid": hwid,
|
|
1067
1277
|
}
|
|
1068
1278
|
# Network failures surface once, as heartbeat_failed / network_error.
|
|
1069
|
-
response_obj = self._post_json("/auth/heartbeat", body
|
|
1279
|
+
response_obj = self._post_json("/auth/heartbeat", body)
|
|
1070
1280
|
if not self._is_success_status(response_obj.get("status")):
|
|
1071
1281
|
raise self._heartbeat_verdict(response_obj)
|
|
1072
1282
|
expected_nonce = str(body.get("nonce", "")).strip()
|
|
@@ -1125,6 +1335,7 @@ class AuthForgeClient:
|
|
|
1125
1335
|
}
|
|
1126
1336
|
if self.ttl_seconds is not None:
|
|
1127
1337
|
body["ttlSeconds"] = self.ttl_seconds
|
|
1338
|
+
# Network failures surface once, as login_failed / network_error.
|
|
1128
1339
|
response_obj = self._post_json("/auth/validate", body)
|
|
1129
1340
|
expected_nonce = str(body.get("nonce", "")).strip()
|
|
1130
1341
|
self._apply_signed_response(
|
|
@@ -1229,9 +1440,7 @@ class AuthForgeClient:
|
|
|
1229
1440
|
self._license_variables = parsed["license_variables"]
|
|
1230
1441
|
self._authenticated = True
|
|
1231
1442
|
|
|
1232
|
-
def _post_json(
|
|
1233
|
-
self, path: str, data: Dict[str, Any], *, skip_failure_hook: bool = False
|
|
1234
|
-
) -> Dict[str, Any]:
|
|
1443
|
+
def _post_json(self, path: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
1235
1444
|
url = f"{self.api_base_url}{path}"
|
|
1236
1445
|
body = dict(data)
|
|
1237
1446
|
rate_attempt = 0
|
|
@@ -1271,8 +1480,6 @@ class AuthForgeClient:
|
|
|
1271
1480
|
network_attempt += 1
|
|
1272
1481
|
time.sleep(NETWORK_RETRY_DELAY)
|
|
1273
1482
|
continue
|
|
1274
|
-
if not skip_failure_hook:
|
|
1275
|
-
self._fail("network_error", exc)
|
|
1276
1483
|
reason = getattr(exc, "reason", exc)
|
|
1277
1484
|
code = "timeout" if isinstance(reason, (socket.timeout, TimeoutError)) else "network_error"
|
|
1278
1485
|
raise AuthForgeError(code, f"url_error: {exc}") from exc
|
|
@@ -1299,6 +1506,11 @@ class AuthForgeClient:
|
|
|
1299
1506
|
return obj
|
|
1300
1507
|
|
|
1301
1508
|
def _get_hwid(self) -> str:
|
|
1509
|
+
machine_id = _read_os_machine_id() or "legacy:" + self._legacy_fingerprint()
|
|
1510
|
+
return hwid_from_machine_id(self.app_id, machine_id)
|
|
1511
|
+
|
|
1512
|
+
# Pre-2.0 fingerprint, kept only as the fallback when no OS machine id is readable.
|
|
1513
|
+
def _legacy_fingerprint(self) -> str:
|
|
1302
1514
|
mac = self._safe_mac_address()
|
|
1303
1515
|
cpu = self._safe_cpu_info()
|
|
1304
1516
|
disk = self._safe_disk_serial()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: authforge-sdk
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0.0
|
|
4
4
|
Summary: Official Python SDK for AuthForge — credit-based license key authentication with Ed25519-verified responses.
|
|
5
5
|
Author: AuthForge
|
|
6
6
|
License: MIT
|
|
@@ -38,7 +38,7 @@ Uses `cryptography` for Ed25519 verification. Works on Python 3.9+.
|
|
|
38
38
|
2. **Run through the grace period.** By default the app keeps running on the signed session with no further network calls. A background check re-verifies the signature locally and fails when the session TTL expires. The grace period equals the session TTL: default 24h, and the server clamps requested values to 1h to 7d.
|
|
39
39
|
3. **Optionally enable online check-ins.** With `online_heartbeat=True`, the SDK also calls `POST /auth/heartbeat` every `heartbeat_interval` seconds for fast revocation and concurrent-use detection.
|
|
40
40
|
|
|
41
|
-
Separately, for machines that can **never** reach the internet, an operator can mint a signed **offline license file (`.authforge`)** in the AuthForge dashboard or Developer API. The SDK verifies it locally with your app public key: see [Offline license files](#offline-license-files-authforge).
|
|
41
|
+
Separately, for machines that can **never** reach the internet, an operator can mint a signed **offline license file (`.authforge`)** in the AuthForge dashboard or Developer API. The SDK verifies it locally with your app public key: see [Offline license files](#offline-license-files-authforge). If the machine can go online **once**, the end user can get that file themselves with `activate_offline()`: see [Self-serve offline activation](#self-serve-offline-activation).
|
|
42
42
|
|
|
43
43
|
## Features
|
|
44
44
|
|
|
@@ -50,8 +50,9 @@ Everything in this list ships in `authforge.py` today:
|
|
|
50
50
|
- **Grace period by default**: after one successful activation, the app runs on the signed session (no network) until the TTL expires.
|
|
51
51
|
- **Online check-ins** (opt-in): periodic `/auth/heartbeat` calls for fast revocation and concurrent-use detection.
|
|
52
52
|
- **Offline license files (`.authforge`)**: `login_from_file()` / `verify_license_file()` verify a cloud-minted, Ed25519-signed file with zero network access for air-gapped machines.
|
|
53
|
+
- **Self-serve offline activation**: `activate_offline()` turns a license key into a lifetime, HWID-bound `.authforge` file with one online call, then runs offline from that file.
|
|
53
54
|
- **Nonce anti-replay**: a fresh 128-bit nonce is sent on every request and the echoed nonce in the signed payload is checked before the response is accepted.
|
|
54
|
-
- **HWID
|
|
55
|
+
- **HWID v2**: per-app device id derived from the OS machine id (macOS `IOPlatformUUID`, Linux `/etc/machine-id`, Windows `MachineGuid`), identical across all AuthForge SDKs for the same app and machine.
|
|
55
56
|
- **`hwid_override`**: bind to any identity instead of the machine (for example `tg:<id>`, `discord:<id>`).
|
|
56
57
|
- **Seat enforcement**: the server binds each HWID into a license's free slots up to `maxHwidSlots`; `hwid_count` / `max_hwid_slots` are surfaced on the result. A shared (unlimited-seat) key skips per-device binding.
|
|
57
58
|
- **Self-ban** (`self_ban()`) for anti-tamper response, both pre-session and post-session.
|
|
@@ -118,7 +119,7 @@ client = AuthForgeClient(
|
|
|
118
119
|
| `on_failure` | callable | `None` | Callback `(reason: str, exc: Exception | None)` on auth failure |
|
|
119
120
|
| `request_timeout` | int | `15` | HTTP request timeout in seconds |
|
|
120
121
|
| `ttl_seconds` | `int \| None` | `None` (server default: 86400) | The grace period duration: how long the app keeps running on the signed session without contacting AuthForge. Server clamps to `[3600, 604800]` (1h to 7d); preserved across check-in refreshes. |
|
|
121
|
-
| `hwid_override` | `str \| None` | `None` | Optional custom hardware/subject identifier. When set to a non-empty value, the SDK uses it instead of machine
|
|
122
|
+
| `hwid_override` | `str \| None` | `None` | Optional custom hardware/subject identifier. When set to a non-empty value, the SDK uses it instead of deriving the HWID from the OS machine id. |
|
|
122
123
|
| `online_heartbeat` | bool (keyword-only) | `False` | Enable online check-ins: periodic `POST /auth/heartbeat` for fast revocation and concurrent-use detection. |
|
|
123
124
|
|
|
124
125
|
### Identity-based binding example (Telegram/Discord)
|
|
@@ -189,12 +190,81 @@ if client.login_from_file("license.authforge"):
|
|
|
189
190
|
print(client.get_license_variables())
|
|
190
191
|
```
|
|
191
192
|
|
|
192
|
-
|
|
193
|
+
Since 2.0 every AuthForge SDK computes the same HWID for the same app id and machine, but HWIDs collected before upgrading to 2.0 no longer match: collect the HWID again after upgrading. After `login_from_file()`, `get_session_kind()` returns `"offline"` (`"online"` after `login()`, `None` when logged out).
|
|
193
194
|
|
|
194
195
|
`verify_license_file()` (module function and client method) performs the same checks without touching client state. Failure codes, in check order: `bad_armor`, `bad_signature`, `unsupported_version`, `malformed_payload`, `wrong_app`, `expired`, `hwid_mismatch`. `login_from_file()` reports them through `on_failure("offline_login_failed", exc)` and returns `False`; it never calls `os._exit`.
|
|
195
196
|
|
|
196
197
|
File format (version 1): PEM-style armor with informational headers, a base64 JSON payload (`v`, `appId`, `licenseKey`, `jti`, `kid`, `issuedAt`, `expiresAt`, `hwid` policy, optional label/variable snapshots) and a detached Ed25519 signature over the UTF-8 bytes of the base64 payload string - the same contract as `/auth/validate`. See `offline_license_vectors.json` for conformance vectors.
|
|
197
198
|
|
|
199
|
+
## Self-serve offline activation
|
|
200
|
+
|
|
201
|
+
A separate mode for apps that sell a one-time license and then run with no network: the end user types the license key once, `activate_offline()` makes **one** online call (`POST /auth/offline/activate`) and gets back a lifetime `.authforge` file bound to this machine's HWID. From then on the app starts with `login_from_file()` and never contacts AuthForge again.
|
|
202
|
+
|
|
203
|
+
How it differs from air-gapped offline files:
|
|
204
|
+
|
|
205
|
+
- It **needs the App Secret**, because it is an online call. Air-gapped builds that only use `login_from_file()` still ship with `app_secret=None`.
|
|
206
|
+
- The app owner opts the app in (app settings in the dashboard). Only **perpetual**, **seat-limited** licenses can self-serve; each license can produce files for at most `maxHwidSlots` distinct machines, ever. HWID resets do not free those machine slots.
|
|
207
|
+
- **Billing:** a new machine costs the app owner **2 credits**, once. The same machine activating again (reinstall, deleted file) gets the identical file back for **free**. A refused activation costs nothing.
|
|
208
|
+
- **A refund or chargeback revokes the license online but cannot reach files already issued.** A lifetime file keeps working on its machine.
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
import os
|
|
212
|
+
import sys
|
|
213
|
+
|
|
214
|
+
from authforge import AuthForgeClient, AuthForgeError
|
|
215
|
+
|
|
216
|
+
LICENSE_PATH = os.path.join(os.path.expanduser("~"), ".myapp", "license.authforge")
|
|
217
|
+
|
|
218
|
+
client = AuthForgeClient(
|
|
219
|
+
app_id="YOUR_APP_ID",
|
|
220
|
+
app_secret="YOUR_APP_SECRET", # required for activate_offline (online call)
|
|
221
|
+
public_key="YOUR_PUBLIC_KEY",
|
|
222
|
+
on_failure=lambda reason, exc: None, # login_from_file reports why it failed here
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
# 1. No network, no credits: run from the file if it is there and valid.
|
|
226
|
+
if not (os.path.exists(LICENSE_PATH) and client.login_from_file(LICENSE_PATH)):
|
|
227
|
+
# 2. No file yet, or it fails (e.g. hwid_mismatch after a hardware change).
|
|
228
|
+
key = input("License key: ").strip()
|
|
229
|
+
try:
|
|
230
|
+
client.activate_offline(key, path=LICENSE_PATH)
|
|
231
|
+
except AuthForgeError as exc:
|
|
232
|
+
if exc.code == "offline_activation_limit_reached":
|
|
233
|
+
print("This license is active on its maximum number of machines. Please contact the vendor.")
|
|
234
|
+
elif exc.code in {"invalid_key", "revoked", "expired"}:
|
|
235
|
+
print("That license key is not valid.")
|
|
236
|
+
elif exc.code == "hwid_mismatch":
|
|
237
|
+
print("No free seat on this license for this machine.")
|
|
238
|
+
elif exc.transient:
|
|
239
|
+
print(f"Could not activate right now ({exc.code}). Check your connection and try again.")
|
|
240
|
+
else:
|
|
241
|
+
print(f"Activation failed: {exc.code}")
|
|
242
|
+
sys.exit(1)
|
|
243
|
+
|
|
244
|
+
print("Licensed:", client.get_license_variables())
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
`activate_offline(license_key, path=None)` returns `True` on success and raises `AuthForgeError` otherwise. It:
|
|
248
|
+
|
|
249
|
+
1. Fails fast with the usual "app_secret is required" `ValueError` (no request) when the client has no App Secret.
|
|
250
|
+
2. Sends `appId`, `appSecret`, `licenseKey`, the same HWID `login()` sends and a fresh nonce, with the same retries as `login()` (`rate_limited` after 2s then 5s, one retry on a network error).
|
|
251
|
+
3. Verifies the returned file with your public key(s), app id and this machine's HWID **before** writing anything. A file that fails raises `offline_file_rejected`; the message includes the verifier's reason (`bad_signature`, `wrong_app`, `hwid_mismatch`, ...).
|
|
252
|
+
4. If `path` is set, writes the file byte-for-byte and atomically (temp file in the same directory, then rename), creating parent directories.
|
|
253
|
+
5. Leaves the client authenticated exactly as `login_from_file()` does: `get_session_kind()` is `"offline"`, variables and `get_offline_license()` are set, no background checks start. `get_offline_license()["replay"]` is `True` when the server returned a file this machine already had.
|
|
254
|
+
|
|
255
|
+
On any failure nothing is written to `path` (an existing file there is left as it was), the call does not authenticate the client (an existing session is kept), and neither `on_failure` nor `os._exit` is called.
|
|
256
|
+
|
|
257
|
+
| Code | Kind | Meaning |
|
|
258
|
+
| --- | --- | --- |
|
|
259
|
+
| `offline_activation_disabled` | fatal | The app has not opted in to self-serve activation |
|
|
260
|
+
| `offline_activation_requires_perpetual` | fatal | The license has an expiry; only perpetual licenses can self-serve |
|
|
261
|
+
| `offline_activation_requires_seats` | fatal | Unlimited-seat (shared) licenses cannot self-serve |
|
|
262
|
+
| `offline_activation_limit_reached` | fatal | Every machine slot on the license is used; the buyer must contact the vendor |
|
|
263
|
+
| `offline_file_rejected` | fatal | SDK code: the returned file failed local verification; nothing was written |
|
|
264
|
+
| `hwid_mismatch` | fatal | No free seat on the license for this machine (server code, not the verifier's) |
|
|
265
|
+
| `file_write_failed` | transient | The verified file could not be written to `path`; activating again is free |
|
|
266
|
+
| `invalid_key`, `revoked`, `expired`, `no_credits`, `rate_limited`, `unexpected_response`, `network_error`, ... | as everywhere else | Existing codes keep their meaning and classification |
|
|
267
|
+
|
|
198
268
|
## Migrating from heartbeat_mode
|
|
199
269
|
|
|
200
270
|
Earlier releases required `heartbeat_mode="LOCAL"` or `"SERVER"`. The argument is now optional and deprecated; it still works but emits a `DeprecationWarning`.
|
|
@@ -217,6 +287,7 @@ For code that still reads it, `client.heartbeat_mode` remains available and refl
|
|
|
217
287
|
|
|
218
288
|
- **1 `login()` or `validate_license()` call = 1 credit** (one `/auth/validate` debit each).
|
|
219
289
|
- **10 online check-ins on the same license = 1 credit** (billed every 10th successful `/auth/heartbeat`). Grace period checks are local and free.
|
|
290
|
+
- **1 new offline activation (`activate_offline()`) = 2 credits**, charged to the app owner; re-activating the same machine is free. `login_from_file()` is always free.
|
|
220
291
|
|
|
221
292
|
A desktop app running 6h/day with online check-ins at a 15-minute interval burns ~3-4 credits/day. The server enforces `/auth/heartbeat` at 6 requests/minute per license key, so keep intervals at 10 seconds or higher and pick the interval based on how fast you need revocations to propagate (they always land on the **next** check-in).
|
|
222
293
|
|
|
@@ -226,8 +297,9 @@ A desktop app running 6h/day with online check-ins at a 15-minute interval burns
|
|
|
226
297
|
|---|---|---|
|
|
227
298
|
| `login(license_key)` | `bool` | Activates: validates the key online and stores the signed session (`sessionToken`, `expiresIn`, `appVariables`, `licenseVariables`) |
|
|
228
299
|
| `validate_license(license_key)` | `ValidateLicenseResult` | Same `/auth/validate` + signatures as `login`; does not store session or start background checks; returns a dict with `valid` / `code` and **never** calls `on_failure` or `os._exit` |
|
|
229
|
-
| `self_ban(...)` | `dict` | Requests `/auth/selfban` to blacklist HWID/IP and optionally revoke (session-authenticated only) |
|
|
300
|
+
| `self_ban(...)` | `dict` | Requests `/auth/selfban` to blacklist HWID/IP and optionally revoke (session-authenticated only). Raises `ValueError` on any failure (a network failure as `AuthForgeError` `network_error` / `timeout`); never calls `on_failure` or `os._exit` |
|
|
230
301
|
| `login_from_file(path_or_text)` | `bool` | Authorizes from an offline `.authforge` file with no network; never starts background checks; failures go to `on_failure("offline_login_failed", …)` |
|
|
302
|
+
| `activate_offline(license_key, path=None)` | `bool` | Self-serve offline activation: one online call returns a lifetime HWID-bound `.authforge` file, verified locally, written atomically to `path` and applied like `login_from_file`. Needs the App Secret; raises `AuthForgeError` on refusal; never calls `on_failure` or `os._exit` |
|
|
231
303
|
| `verify_license_file(path_or_text, *, now=None)` | `VerifyLicenseFileResult` | Verifies a `.authforge` file with this client's app id / keys / HWID without changing state |
|
|
232
304
|
| `get_offline_license()` | `dict \| None` | Metadata of the offline file in use (`jti`, `expires_at`, `hwid_policy`, …) |
|
|
233
305
|
| `get_session_kind()` | `"online" \| "offline" \| None` | Which kind of session the client holds (`None` when logged out) |
|
|
@@ -247,7 +319,11 @@ If authentication fails (activation rejected, check-in fails, signature mismatch
|
|
|
247
319
|
**`validate_license()`** does not trigger `on_failure` or `os._exit`: check `result["valid"]` and `result["code"]`.
|
|
248
320
|
|
|
249
321
|
Recognized server errors (`KNOWN_SERVER_ERRORS`):
|
|
250
|
-
`invalid_app`, `invalid_key`, `expired`, `revoked`, `hwid_mismatch`, `no_credits`, `app_burn_cap_reached`, `blocked`, `rate_limited`, `replay_detected`, `app_disabled`, `session_expired`, `revoke_requires_session`, `bad_request`, `malformed_request`, `demo_quota_exceeded`, `system_error`. Codes added to the server later are passed through unchanged.
|
|
322
|
+
`invalid_app`, `invalid_key`, `expired`, `revoked`, `hwid_mismatch`, `no_credits`, `app_burn_cap_reached`, `blocked`, `rate_limited`, `replay_detected`, `app_disabled`, `session_expired`, `revoke_requires_session`, `bad_request`, `malformed_request`, `demo_quota_exceeded`, `system_error`, and from `activate_offline()`: `offline_activation_disabled`, `offline_activation_requires_perpetual`, `offline_activation_requires_seats`, `offline_activation_limit_reached`, plus the SDK code `offline_file_rejected`. Codes added to the server later are passed through unchanged.
|
|
323
|
+
|
|
324
|
+
**`activate_offline()`** does not trigger `on_failure` or `os._exit` either: it raises `AuthForgeError` (see [Self-serve offline activation](#self-serve-offline-activation)).
|
|
325
|
+
|
|
326
|
+
**`self_ban()`** does not trigger `on_failure` or `os._exit` either: every failure raises, a server rejection as `ValueError(code)` and a network failure as `AuthForgeError` with `exc.code` `network_error` or `timeout`.
|
|
251
327
|
|
|
252
328
|
Request retries are automatic inside the internal HTTP layer:
|
|
253
329
|
- `rate_limited`, or HTTP 429 with no error code: retry after 2s, then 5s (max 3 attempts total)
|
|
@@ -266,10 +342,10 @@ A failed check-in only counts as an AuthForge verdict when the body is a JSON ob
|
|
|
266
342
|
|
|
267
343
|
| Kind | Codes | What the SDK does |
|
|
268
344
|
|---|---|---|
|
|
269
|
-
| Fatal | `revoked`, `expired`, `hwid_mismatch` (the HWID is no longer bound to the license, for example after an HWID reset), `blocked` (HWID/IP blacklisted or not whitelisted), `session_expired` (also raised locally when the grace period or session TTL runs out), `malformed_request`, `app_disabled`, `invalid_app`, `signature_mismatch` | Clears the stored session (as `logout()` does) and stops background checks **before** calling `on_failure`, so the grace period cannot keep the app running on it. `on_failure` may call `login()` again. |
|
|
345
|
+
| Fatal | `revoked`, `expired`, `hwid_mismatch` (the HWID is no longer bound to the license, for example after an HWID reset), `blocked` (HWID/IP blacklisted or not whitelisted), `session_expired` (also raised locally when the grace period or session TTL runs out), `malformed_request`, `app_disabled`, `invalid_app`, `signature_mismatch` (plus the `activate_offline()`-only codes `offline_activation_*` and `offline_file_rejected`) | Clears the stored session (as `logout()` does) and stops background checks **before** calling `on_failure`, so the grace period cannot keep the app running on it. `on_failure` may call `login()` again. |
|
|
270
346
|
| Transient | Everything else: `network_error`, `timeout`, `rate_limited`, `system_error`, `no_credits`, `demo_quota_exceeded`, `app_burn_cap_reached`, `bad_request`, `invalid_key`, every `http_error_<status>`, `invalid_json_response`, `unexpected_response`, `nonce_mismatch`, any unrecognized code | Keeps the session and checks in again on the next `heartbeat_interval`. Once the signed session's TTL has passed, the next transient failure is reported as a fatal `session_expired` instead. |
|
|
271
347
|
|
|
272
|
-
Transient failures only keep checking in if `on_failure` returns normally. Without a callback, a transient failure prints `AuthForge: background check failed (<code>); retrying next interval` to stderr and check-ins continue; a fatal one, including the `session_expired` a transient failure becomes once the session TTL has passed, still ends the process with `os._exit(1)`.
|
|
348
|
+
Transient failures only keep checking in if `on_failure` returns normally. Without a callback, a transient failure prints `AuthForge: background check failed (<code>); retrying next interval` to stderr and check-ins continue; a fatal one, including the `session_expired` a transient failure becomes once the session TTL has passed, still ends the process with `os._exit(1)`. Network failures are reported once, with code `network_error` or `timeout`: as `heartbeat_failed` from check-ins and as `login_failed` from `login()`, never as a separate `network_error` reason. `activate_offline()` and `self_ban()` raise them as `AuthForgeError` instead.
|
|
273
349
|
|
|
274
350
|
`on_failure` runs on the background heartbeat thread with no SDK lock held, so calling `logout()`, `is_authenticated()` or `login()` from it is safe. A check-in that is still in flight when you call `logout()` or `login()` is discarded: it cannot restore the old session or report a failure for it.
|
|
275
351
|
|
|
@@ -339,11 +415,12 @@ client.self_ban(
|
|
|
339
415
|
- Uses post-session mode when a session token is available (`session_token` arg or current SDK session).
|
|
340
416
|
- Falls back to pre-session mode using `license_key` + nonce + app secret.
|
|
341
417
|
- In pre-session mode, revoke is forced off client-side to avoid unsafe key revocations.
|
|
418
|
+
- Every failure raises: a server rejection as `ValueError(code)`, a network failure (after one retry) as `AuthForgeError` with `exc.code` `network_error` or `timeout`. `self_ban()` never calls `on_failure` or `os._exit`, with or without a callback.
|
|
342
419
|
- Not available after `login_from_file()`: offline sessions have no server session, so `self_ban()` with no explicit `license_key` / `session_token` raises `ValueError("offline_session")` without contacting the server.
|
|
343
420
|
|
|
344
421
|
## How It Works
|
|
345
422
|
|
|
346
|
-
1. **Activate**: `login()` uses `hwid_override` if provided; otherwise it
|
|
423
|
+
1. **Activate**: `login()` uses `hwid_override` if provided; otherwise it derives the HWID from the OS machine id (see [Hardware ID](#hardware-id)). It then 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 with a TTL. The SDK verifies the Ed25519 signature and nonce to prevent replay attacks.
|
|
347
424
|
|
|
348
425
|
2. **Background checks**: a daemon thread wakes at the configured interval. By default it enforces the grace period: it re-verifies the stored signature and checks expiry without network calls. With `online_heartbeat=True`, it instead sends `/auth/heartbeat` with a fresh nonce and verifies the response.
|
|
349
426
|
|
|
@@ -351,18 +428,29 @@ client.self_ban(
|
|
|
351
428
|
|
|
352
429
|
## Hardware ID
|
|
353
430
|
|
|
354
|
-
The
|
|
355
|
-
|
|
356
|
-
-
|
|
357
|
-
|
|
431
|
+
The HWID (v2, since 2.0) is
|
|
432
|
+
|
|
433
|
+
`SHA256("authforge-hwid-v2|" + app_id + "|" + machine_id)` as lowercase hex,
|
|
434
|
+
|
|
435
|
+
where `machine_id` is the operating system's own machine id, trimmed and lowercased:
|
|
436
|
+
|
|
437
|
+
| OS | Source |
|
|
438
|
+
| -- | ------ |
|
|
439
|
+
| macOS | `IOPlatformUUID` (`/usr/sbin/ioreg -rd1 -c IOPlatformExpertDevice`) |
|
|
440
|
+
| Linux | `/etc/machine-id`, then `/var/lib/dbus/machine-id` |
|
|
441
|
+
| Windows | `HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid` (64-bit registry view, via `winreg`) |
|
|
442
|
+
|
|
443
|
+
These ids survive network changes, renames, disk usage and USB devices. Including the app id means two different apps on the same machine get unrelated HWIDs. Every AuthForge SDK computes the same value for the same app id and machine; `hwid_vectors.json` pins this. The HWID is sent with every auth request so the server can enforce per-device license limits.
|
|
444
|
+
|
|
445
|
+
If no valid OS id can be read (for example a container without `/etc/machine-id`), the SDK falls back to `legacy:<the pre-2.0 fingerprint of MAC, CPU and disk serial>` as the machine id. Containers built from one image can also share a baked-in machine id, so for containers prefer `hwid_override` with an identifier you control.
|
|
358
446
|
|
|
359
|
-
|
|
447
|
+
The helpers `hwid_from_machine_id`, `normalize_machine_id`, `parse_ioreg_platform_uuid` and `parse_reg_query_machine_guid` are exported for tooling and tests.
|
|
360
448
|
|
|
361
449
|
For non-device identities (for example Telegram users), pass `hwid_override` such as `tg:<user_id>`.
|
|
362
450
|
|
|
363
451
|
## Test Vectors
|
|
364
452
|
|
|
365
|
-
The shared `test_vectors.json` file validates cross-language Ed25519 verification behavior. `offline_license_vectors.json` (generated from a fixed test seed in the Node SDK repo) is the cross-SDK conformance suite for `.authforge` offline license files: good files plus the `bad_signature`, wrong key, `wrong_app`, `expired`, `hwid_mismatch`, `unsupported_version` and `bad_armor` rejects.
|
|
453
|
+
The shared `test_vectors.json` file validates cross-language Ed25519 verification behavior. `offline_license_vectors.json` (generated from a fixed test seed in the Node SDK repo) is the cross-SDK conformance suite for `.authforge` offline license files: good files plus the `bad_signature`, wrong key, `wrong_app`, `expired`, `hwid_mismatch`, `unsupported_version` and `bad_armor` rejects. `self_serve_activation_vectors.json` is the cross-SDK suite for `activate_offline()`: mocked `/auth/offline/activate` responses with the expected written file, session and error code. `hwid_vectors.json` (generated by `authforge-node/generate_hwid_vectors.mjs`) is the cross-SDK suite for HWID v2: derivation, machine id normalization, and the `ioreg` / `reg query` parsers.
|
|
366
454
|
|
|
367
455
|
## Requirements
|
|
368
456
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "authforge-sdk"
|
|
7
|
-
version = "
|
|
7
|
+
version = "2.0.0"
|
|
8
8
|
description = "Official Python SDK for AuthForge — credit-based license key authentication with Ed25519-verified responses."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|